Files
fuel-price/docs/superpowers/plans/2026-04-04-filament-admin-panel.md
Ovidiu U c2c16c928b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add UserResource with is_admin toggle and delete
User management resource with editable is_admin field, postcode support,
admin filter, and inline delete action. Includes list and edit pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:31:55 +01:00

2045 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 12 | ✓ |
| `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 59 (`$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.