feat/041-inventory-ui #44

Merged
ahmido merged 4 commits from feat/041-inventory-ui into dev 2026-01-07 17:10:57 +00:00
14 changed files with 570 additions and 7 deletions
Showing only changes of commit c0301cd253 - Show all commits

View File

@ -0,0 +1,30 @@
<?php
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class InventoryCoverage extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Coverage';
protected string $view = 'filament.pages.inventory-coverage';
/**
* @var array<int, array<string, mixed>>
*/
public array $supportedTypes = [];
public function mount(): void
{
$types = config('tenantpilot.supported_policy_types', []);
$this->supportedTypes = is_array($types) ? $types : [];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\Tenant;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class InventoryLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Inventory';
protected string $view = 'filament.pages.inventory-landing';
public function getInventoryItemsUrl(): string
{
return InventoryItemResource::getUrl('index', tenant: Tenant::current());
}
public function getSyncRunsUrl(): string
{
return InventorySyncRunResource::getUrl('index', tenant: Tenant::current());
}
public function getCoverageUrl(): string
{
return InventoryCoverage::getUrl(tenant: Tenant::current());
}
}

View File

@ -0,0 +1,161 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem;
use App\Models\Tenant;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class InventoryItemResource extends Resource
{
protected static ?string $model = InventoryItem::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Inventory Item')
->schema([
TextEntry::make('display_name')->label('Name'),
TextEntry::make('policy_type')
->label('Type')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type),
TextEntry::make('category')
->badge()
->state(fn (InventoryItem $record): string => $record->category
?: (static::typeMeta($record->policy_type)['category'] ?? 'Unknown')),
TextEntry::make('platform')->badge(),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
TextEntry::make('last_seen_run_id')
->label('Last sync run')
->url(function (InventoryItem $record): ?string {
if (! $record->last_seen_run_id) {
return null;
}
return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current());
})
->openUrlInNewTab(),
TextEntry::make('support_restore')
->label('Restore')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
TextEntry::make('support_risk')
->label('Risk')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal'),
])
->columns(2)
->columnSpanFull(),
Section::make('Metadata (Safe Subset)')
->schema([
ViewEntry::make('meta_jsonb')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventoryItem $record) => $record->meta_jsonb ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
$typeOptions = collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)])
->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '')
->all();
$categoryOptions = collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
->filter(fn ($value, $key) => is_string($key) && $key !== '')
->all();
return $table
->defaultSort('last_seen_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('display_name')
->label('Name')
->searchable(),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state),
Tables\Columns\TextColumn::make('category')
->badge(),
Tables\Columns\TextColumn::make('platform')
->badge(),
Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
Tables\Columns\TextColumn::make('lastSeenRun.status')
->label('Run')
->badge(),
])
->filters([
Tables\Filters\SelectFilter::make('policy_type')
->options($typeOptions)
->searchable(),
Tables\Filters\SelectFilter::make('category')
->options($categoryOptions)
->searchable(),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->with('lastSeenRun');
}
public static function getPages(): array
{
return [
'index' => Pages\ListInventoryItems::route('/'),
'view' => Pages\ViewInventoryItem::route('/{record}'),
];
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Resources\InventoryItemResource;
use Filament\Resources\Pages\ListRecords;
class ListInventoryItems extends ListRecords
{
protected static string $resource = InventoryItemResource::class;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Resources\InventoryItemResource;
use Filament\Resources\Pages\ViewRecord;
class ViewInventoryItem extends ViewRecord
{
protected static string $resource = InventoryItemResource::class;
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class InventorySyncRunResource extends Resource
{
protected static ?string $model = InventorySyncRun::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Sync Run')
->schema([
TextEntry::make('status')
->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('errors_count')->label('Errors')->numeric(),
TextEntry::make('had_errors')->label('Had errors')->badge(),
])
->columns(2)
->columnSpanFull(),
Section::make('Selection Payload')
->schema([
ViewEntry::make('selection_payload')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->selection_payload ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Error Summary')
->schema([
ViewEntry::make('error_codes')
->label('Error codes')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->error_codes ?? [])
->columnSpanFull(),
ViewEntry::make('error_context')
->label('Safe error context')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->error_context ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
Tables\Columns\TextColumn::make('selection_hash')
->label('Selection')
->copyable()
->limit(12),
Tables\Columns\TextColumn::make('started_at')->since(),
Tables\Columns\TextColumn::make('finished_at')->since(),
Tables\Columns\TextColumn::make('items_observed_count')
->label('Observed')
->numeric(),
Tables\Columns\TextColumn::make('items_upserted_count')
->label('Upserted')
->numeric(),
Tables\Columns\TextColumn::make('errors_count')
->label('Errors')
->numeric(),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
public static function getPages(): array
{
return [
'index' => Pages\ListInventorySyncRuns::route('/'),
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
];
}
private static function statusColor(?string $status): string
{
return match ($status) {
InventorySyncRun::STATUS_SUCCESS => 'success',
InventorySyncRun::STATUS_PARTIAL => 'warning',
InventorySyncRun::STATUS_FAILED => 'danger',
InventorySyncRun::STATUS_SKIPPED => 'gray',
InventorySyncRun::STATUS_RUNNING => 'info',
default => 'gray',
};
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use Filament\Resources\Pages\ListRecords;
class ListInventorySyncRuns extends ListRecords
{
protected static string $resource = InventorySyncRunResource::class;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use Filament\Resources\Pages\ViewRecord;
class ViewInventorySyncRun extends ViewRecord
{
protected static string $resource = InventorySyncRunResource::class;
}

View File

@ -0,0 +1,28 @@
<x-filament::page>
<x-filament::section>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left border-b border-gray-200 dark:border-gray-800">
<th class="py-2 pr-4 font-medium">Type</th>
<th class="py-2 pr-4 font-medium">Label</th>
<th class="py-2 pr-4 font-medium">Category</th>
<th class="py-2 pr-4 font-medium">Restore</th>
<th class="py-2 pr-4 font-medium">Risk</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($supportedTypes as $row)
<tr>
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</x-filament::section>
</x-filament::page>

View File

@ -0,0 +1,23 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
Browse inventory items, inspect sync runs, and review coverage/capabilities.
</div>
<div class="flex flex-wrap gap-3">
<x-filament::button tag="a" :href="$this->getInventoryItemsUrl()">
Inventory Items
</x-filament::button>
<x-filament::button tag="a" color="gray" :href="$this->getSyncRunsUrl()">
Sync Runs
</x-filament::button>
<x-filament::button tag="a" color="gray" :href="$this->getCoverageUrl()">
Coverage
</x-filament::button>
</div>
</div>
</x-filament::section>
</x-filament::page>

View File

@ -1,9 +1,9 @@
# Tasks: Inventory UI
- [ ] T001 Inventory landing page + navigation
- [ ] T002 Inventory list views per type/category
- [ ] T003 Inventory detail view with capability/support indicator
- [ ] T004 Sync runs list + detail
- [ ] T005 Coverage/capabilities view derived from config/contracts
- [ ] T006 Authorization + tenant isolation tests
- [ ] T007 Performance sanity checks (pagination/search)
- [x] T001 Inventory landing page + navigation
- [x] T002 Inventory list views per type/category
- [x] T003 Inventory detail view with capability/support indicator
- [x] T004 Sync runs list + detail
- [x] T005 Coverage/capabilities view derived from config/contracts
- [x] T006 Authorization + tenant isolation tests
- [x] T007 Performance sanity checks (pagination/search)

View File

@ -0,0 +1,41 @@
<?php
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory items are listed for the active tenant', function () {
$tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Item A',
'policy_type' => 'deviceConfiguration',
'external_id' => 'item-a',
'platform' => 'windows',
]);
InventoryItem::factory()->create([
'tenant_id' => $otherTenant->getKey(),
'display_name' => 'Item B',
'policy_type' => 'deviceConfiguration',
'external_id' => 'item-b',
'platform' => 'windows',
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Item A')
->assertDontSee('Item B');
});

View File

@ -0,0 +1,26 @@
<?php
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\InventoryLanding;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory landing and coverage pages load for a tenant', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(InventoryLanding::getUrl(tenant: $tenant))
->assertOk();
$this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk()
->assertSee('Coverage');
});

View File

@ -0,0 +1,37 @@
<?php
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory sync runs are listed for the active tenant', function () {
$tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'selection_hash' => str_repeat('a', 64),
'status' => InventorySyncRun::STATUS_SUCCESS,
]);
InventorySyncRun::factory()->create([
'tenant_id' => $otherTenant->getKey(),
'selection_hash' => str_repeat('b', 64),
'status' => InventorySyncRun::STATUS_SUCCESS,
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
$otherTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee(str_repeat('a', 12))
->assertDontSee(str_repeat('b', 12));
});