# 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
<?php

use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

it('denies non-admin users access to admin panel', function () {
    $user = User::factory()->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
<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class AdminSeeder extends Seeder
{
    public function run(): void
    {
        User::updateOrCreate(
            ['email' => '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
<?php

namespace App\Providers\Filament;

use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->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
<?php

namespace App\Models;

use Database\Factories\ApiLogFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])]
class ApiLog extends Model
{
    /** @use HasFactory<ApiLogFactory> */
    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
<?php

namespace Database\Factories;

use App\Models\ApiLog;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<ApiLog> */
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
<?php

namespace App\Models;

use Database\Factories\BrentPriceFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * @property Carbon $date
 * @property string $price_usd
 */
#[Fillable(['date', 'price_usd'])]
class BrentPrice extends Model
{
    /** @use HasFactory<BrentPriceFactory> */
    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
<?php

namespace Database\Factories;

use App\Models\BrentPrice;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<BrentPrice> */
class BrentPriceFactory extends Factory
{
    /** @var array<int, string> */
    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
<?php

namespace App\Models;

use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * @property int $id
 * @property Carbon $predicted_for
 * @property PredictionSource $source
 * @property TrendDirection $direction
 * @property int $confidence
 * @property string|null $reasoning
 * @property Carbon $generated_at
 */
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
{
    /** @use HasFactory<PricePredictionFactory> */
    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
<?php

namespace Database\Factories;

use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\PricePrediction;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<PricePrediction> */
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
<?php

use App\Jobs\PollFuelPricesJob;
use Illuminate\Support\Facades\Queue;

it('dispatches to the default queue', function () {
    Queue::fake();

    PollFuelPricesJob::dispatch();

    Queue::assertPushed(PollFuelPricesJob::class);
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
php artisan test --compact --filter=PollFuelPricesJobTest
```
Expected: FAIL — class not found

- [ ] **Step 3: Create PollFuelPricesJob**

Create `app/Jobs/PollFuelPricesJob.php`:

```php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;

class PollFuelPricesJob implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        Artisan::call('fuel:poll', ['--full' => 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
<?php

use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
use App\Models\ApiLog;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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
<?php

namespace App\Filament\Resources\ApiLogResource\Pages;

use App\Filament\Resources\ApiLogResource;
use Filament\Resources\Pages\ListRecords;

class ListApiLogs extends ListRecords
{
    protected static string $resource = ApiLogResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 4: Create the ViewApiLog page**

Create `app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php`:

```php
<?php

namespace App\Filament\Resources\ApiLogResource\Pages;

use App\Filament\Resources\ApiLogResource;
use Filament\Resources\Pages\ViewRecord;

class ViewApiLog extends ViewRecord
{
    protected static string $resource = ApiLogResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 5: Create ApiLogResource**

Create `app/Filament/Resources/ApiLogResource.php`:

```php
<?php

namespace App\Filament\Resources;

use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
use App\Filament\Resources\ApiLogResource\Pages\ViewApiLog;
use App\Models\ApiLog;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class ApiLogResource extends Resource
{
    protected static ?string $model = ApiLog::class;

    protected static ?string $navigationIcon = 'heroicon-o-server';

    protected static ?string $navigationGroup = 'System';

    protected static ?string $navigationLabel = 'API Logs';

    protected static ?int $navigationSort = 1;

    public static function table(Table $table): Table
    {
        return $table
            ->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
<?php

use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use Filament\Actions\DeleteAction;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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
<?php

namespace App\Filament\Resources\UserResource\Pages;

use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\ListRecords;

class ListUsers extends ListRecords
{
    protected static string $resource = UserResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 4: Create the EditUser page**

Create `app/Filament/Resources/UserResource/Pages/EditUser.php`:

```php
<?php

namespace App\Filament\Resources\UserResource\Pages;

use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\EditRecord;

class EditUser extends EditRecord
{
    protected static string $resource = UserResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 5: Create UserResource**

Create `app/Filament/Resources/UserResource.php`:

```php
<?php

namespace App\Filament\Resources;

use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\User;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;

class UserResource extends Resource
{
    protected static ?string $model = User::class;

    protected static ?string $navigationIcon = 'heroicon-o-users';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form->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
<?php

use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Models\PricePrediction;
use App\Models\User;
use Filament\Actions\Testing\TestAction;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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
<?php

namespace App\Filament\Resources\OilPredictionResource\Pages;

use App\Filament\Resources\OilPredictionResource;
use Filament\Resources\Pages\ViewRecord;

class ViewOilPrediction extends ViewRecord
{
    protected static string $resource = OilPredictionResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 4: Create the ListOilPredictions page with header action**

Create `app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php`:

```php
<?php

namespace App\Filament\Resources\OilPredictionResource\Pages;

use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;

class ListOilPredictions extends ListRecords
{
    protected static string $resource = OilPredictionResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Action::make('runPrediction')
                ->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
<?php

namespace App\Filament\Resources;

use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class OilPredictionResource extends Resource
{
    protected static ?string $model = PricePrediction::class;

    protected static ?string $navigationIcon = 'heroicon-o-beaker';

    protected static ?string $navigationGroup = 'Data';

    protected static ?string $navigationLabel = 'Oil Predictions';

    protected static ?int $navigationSort = 3;

    public static function table(Table $table): Table
    {
        return $table
            ->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
<?php

use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
use App\Models\BrentPrice;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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
<?php

namespace App\Filament\Widgets;

use App\Models\BrentPrice;
use Filament\Widgets\ChartWidget;

class BrentPriceChartWidget extends ChartWidget
{
    protected ?string $heading = 'Brent Crude — Last 30 Days (USD/barrel)';

    protected ?string $pollingInterval = null;

    protected function getData(): array
    {
        $prices = BrentPrice::orderBy('date')
            ->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
<?php

namespace App\Filament\Resources\BrentPriceResource\Pages;

use App\Filament\Resources\BrentPriceResource;
use App\Filament\Widgets\BrentPriceChartWidget;
use Filament\Resources\Pages\ListRecords;

class ListBrentPrices extends ListRecords
{
    protected static string $resource = BrentPriceResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }

    protected function getHeaderWidgets(): array
    {
        return [
            BrentPriceChartWidget::class,
        ];
    }
}
```

- [ ] **Step 5: Create BrentPriceResource**

Create `app/Filament/Resources/BrentPriceResource.php`:

```php
<?php

namespace App\Filament\Resources;

use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
use App\Models\BrentPrice;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

class BrentPriceResource extends Resource
{
    protected static ?string $model = BrentPrice::class;

    protected static ?string $navigationIcon = 'heroicon-o-currency-dollar';

    protected static ?string $navigationGroup = 'Data';

    protected static ?string $navigationLabel = 'Brent Prices';

    protected static ?int $navigationSort = 2;

    public static function table(Table $table): Table
    {
        return $table
            ->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
<?php

use App\Filament\Resources\StationResource\Pages\ListStations;
use App\Models\Station;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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
<?php

namespace App\Filament\Resources\StationResource\Pages;

use App\Filament\Resources\StationResource;
use Filament\Resources\Pages\ViewRecord;

class ViewStation extends ViewRecord
{
    protected static string $resource = StationResource::class;

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 4: Create the ListStations page with header action**

Create `app/Filament/Resources/StationResource/Pages/ListStations.php`:

```php
<?php

namespace App\Filament\Resources\StationResource\Pages;

use App\Filament\Resources\StationResource;
use App\Jobs\PollFuelPricesJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;

class ListStations extends ListRecords
{
    protected static string $resource = StationResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Action::make('triggerFullPoll')
                ->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
<?php

namespace App\Filament\Resources;

use App\Filament\Resources\StationResource\Pages\ListStations;
use App\Filament\Resources\StationResource\Pages\ViewStation;
use App\Models\Station;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;

class StationResource extends Resource
{
    protected static ?string $model = Station::class;

    protected static ?string $navigationIcon = 'heroicon-o-map-pin';

    protected static ?string $navigationGroup = 'Data';

    protected static ?string $navigationLabel = 'Stations';

    protected static ?int $navigationSort = 1;

    public static function table(Table $table): Table
    {
        return $table
            ->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
<?php

use App\Filament\Widgets\StatsOverviewWidget;
use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Station;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->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
<?php

namespace App\Filament\Widgets;

use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Station;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class StatsOverviewWidget extends BaseWidget
{
    protected ?string $pollingInterval = '30s';

    protected function getStats(): array
    {
        return [
            $this->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.
