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>
60 KiB
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, unrunapp/Models/User.php— implementsFilamentUser, hascanAccessPanel()checkingis_adminapp/Providers/Filament/AdminPanelProvider.php— scaffolded, needsauthGuard+ 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; nois_adminstate 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
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
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
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():
public function admin(): static
{
return $this->state(['is_admin' => true]);
}
- Step 5: Create AdminSeeder
Create database/seeders/AdminSeeder.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:
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
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
php artisan test --compact --filter=AdminAccessTest
Expected: PASS (both tests green)
- Step 9: Commit
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
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
php artisan test --compact --filter=AdminAccessTest
Expected: PASS
- Step 3: Commit
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
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
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
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
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
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
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
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
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
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
php artisan test --compact --filter=PollFuelPricesJobTest
Expected: FAIL — class not found
- Step 3: Create PollFuelPricesJob
Create app/Jobs/PollFuelPricesJob.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
php artisan test --compact --filter=PollFuelPricesJobTest
Expected: PASS
- Step 5: Commit
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
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
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
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
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
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
php artisan test --compact --filter=ApiLogResourceTest
Expected: PASS
- Step 7: Run Pint
vendor/bin/pint --dirty --format agent
- Step 8: Commit
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
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
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
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
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
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
php artisan test --compact --filter=UserResourceTest
Expected: PASS
- Step 7: Run Pint
vendor/bin/pint --dirty --format agent
- Step 8: Commit
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
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
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
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
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
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
php artisan test --compact --filter=OilPredictionResourceTest
Expected: PASS
- Step 7: Run Pint
vendor/bin/pint --dirty --format agent
- Step 8: Commit
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
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
php artisan test --compact --filter=BrentPriceResourceTest
Expected: FAIL — class not found
- Step 3: Create BrentPriceChartWidget
Create app/Filament/Widgets/BrentPriceChartWidget.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
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
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
php artisan test --compact --filter=BrentPriceResourceTest
Expected: PASS
- Step 7: Run Pint
vendor/bin/pint --dirty --format agent
- Step 8: Commit
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
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
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
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
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
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
php artisan test --compact --filter=StationResourceTest
Expected: PASS
- Step 7: Run Pint
vendor/bin/pint --dirty --format agent
- Step 8: Commit
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
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
php artisan test --compact --filter=StatsOverviewWidgetTest
Expected: FAIL — class not found
- Step 3: Create StatsOverviewWidget
Create app/Filament/Widgets/StatsOverviewWidget.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:
use App\Filament\Widgets\StatsOverviewWidget;
// ... existing imports ...
->widgets([
AccountWidget::class,
StatsOverviewWidget::class,
])
- Step 5: Run all admin tests to verify nothing broke
php artisan test --compact tests/Feature/Admin/
Expected: all green
- Step 6: Run Pint
vendor/bin/pint --dirty --format agent
- Step 7: Commit
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.