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