TenantAtlas/app/Filament/Pages/InventoryCoverage.php
ahmido 45a804970e feat: complete admin canonical tenant rollout (#165)
## Summary
- complete Spec 136 canonical admin tenant rollout across admin-visible and shared Filament surfaces
- add the shared panel-aware tenant resolver helper, persisted filter-state synchronization, and admin navigation segregation for tenant-sensitive resources
- expand regression, guard, and parity coverage for admin-path tenant resolution, stale filters, workspace-wide tenant-default surfaces, and panel split behavior

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php`
- `vendor/bin/sail artisan test --compact --filter='CanonicalAdminTenantFilterState|PolicyResource|BackupSchedule|BackupSet|FindingResource|BaselineCompareLanding|RestoreRunResource|InventoryItemResource|PolicyVersionResource|ProviderConnectionResource|TenantDiagnostics|InventoryCoverage|InventoryKpiHeader|AuditLog|EntraGroup'`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliance is preserved with Filament v5.
- Provider registration remains unchanged in `bootstrap/providers.php`.
- `PolicyResource` and `PolicyVersionResource` have admin global search disabled explicitly; `EntraGroupResource` keeps admin-aware scoped search with a View page.
- Destructive and governance-sensitive actions retain existing confirmation and authorization behavior while using canonical tenant parity.
- No new assets were introduced, so deployment asset strategy is unchanged and does not add new `filament:assets` work.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #165
2026-03-13 08:09:20 +00:00

454 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Support\Enums\FontFamily;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum;
class InventoryCoverage extends Page implements HasTable
{
use InteractsWithTable;
use ResolvesPanelTenantContext;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
protected static ?int $navigationSort = 3;
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Coverage';
protected static ?string $cluster = InventoryCluster::class;
protected string $view = 'filament.pages.inventory-coverage';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canAccess(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
public function table(Table $table): Table
{
return $table
->searchable()
->searchPlaceholder('Search by policy type or label')
->defaultSort('label')
->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->filterRows(
rows: $this->coverageRows(),
search: $search,
filters: $filters,
);
$rows = $this->sortRows(
rows: $rows,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
);
return $this->paginateRows(
rows: $rows,
page: $page,
recordsPerPage: $recordsPerPage,
);
})
->columns([
TextColumn::make('type')
->label('Type')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap(),
TextColumn::make('label')
->label('Label')
->sortable()
->badge()
->formatStateUsing(function (?string $state, array $record): string {
return TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
)->label;
})
->color(function (?string $state, array $record): string {
return TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
)->color;
})
->icon(function (?string $state, array $record): ?string {
return TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
)->icon;
})
->iconColor(function (?string $state, array $record): ?string {
$spec = TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
);
return $spec->iconColor ?? $spec->color;
})
->wrap(),
TextColumn::make('risk')
->label('Risk')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
TextColumn::make('restore')
->label('Restore')
->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
: 'Not provided';
})
->color(function (?string $state): string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->color
: 'gray';
})
->icon(function (?string $state): ?string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->icon
: 'heroicon-m-minus-circle';
})
->iconColor(function (?string $state): ?string {
if (! filled($state)) {
return 'gray';
}
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
return $spec->iconColor ?? $spec->color;
}),
TextColumn::make('category')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
->toggleable()
->wrap(),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(),
IconColumn::make('dependencies')
->label('Dependencies')
->boolean()
->trueIcon('heroicon-m-check-circle')
->falseIcon('heroicon-m-minus-circle')
->trueColor('success')
->falseColor('gray')
->alignCenter()
->toggleable(),
])
->filters($this->tableFilters())
->emptyStateHeading('No coverage entries match this view')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->resetTable();
}),
])
->actions([])
->bulkActions([]);
}
/**
* @return array<int, SelectFilter>
*/
protected function tableFilters(): array
{
$filters = [
SelectFilter::make('category')
->label('Category')
->options($this->categoryFilterOptions()),
];
if ($this->restoreFilterOptions() !== []) {
$filters[] = SelectFilter::make('restore')
->label('Restore mode')
->options($this->restoreFilterOptions());
}
return $filters;
}
/**
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function coverageRows(): Collection
{
$resolver = app(CoverageCapabilitiesResolver::class);
$supported = $this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::supported(),
segment: 'policy',
sourceOrderOffset: 0,
resolver: $resolver,
);
return $supported->merge($this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::foundations(),
segment: 'foundation',
sourceOrderOffset: $supported->count(),
resolver: $resolver,
));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function mapCoverageRows(
array $rows,
string $segment,
int $sourceOrderOffset,
CoverageCapabilitiesResolver $resolver
): Collection {
return collect($rows)
->values()
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
$type = (string) ($row['type'] ?? '');
if ($type === '') {
return [];
}
$key = "{$segment}:{$type}";
$restore = $row['restore'] ?? null;
$risk = $row['risk'] ?? 'n/a';
return [
$key => [
'__key' => $key,
'key' => $key,
'segment' => $segment,
'type' => $type,
'label' => (string) ($row['label'] ?? $type),
'category' => (string) ($row['category'] ?? 'Other'),
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
'restore' => is_string($restore) ? $restore : null,
'risk' => is_string($risk) ? $risk : 'n/a',
'source_order' => $sourceOrderOffset + $index,
],
];
});
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @param array<string, mixed> $filters
* @return Collection<string, array<string, mixed>>
*/
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$category = $filters['category']['value'] ?? null;
$restore = $filters['restore']['value'] ?? null;
return $rows
->when(
$normalizedSearch !== '',
function (Collection $rows) use ($normalizedSearch): Collection {
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
return str_contains(Str::lower((string) $row['type']), $normalizedSearch)
|| str_contains(Str::lower((string) $row['label']), $normalizedSearch);
});
},
)
->when(
filled($category),
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
)
->when(
filled($restore),
fn (Collection $rows): Collection => $rows->where('restore', (string) $restore),
);
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
if ($sortColumn === null) {
return $rows->sortBy('source_order');
}
$records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
if ($comparison === 0) {
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
}
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
protected function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
/**
* @return array<string, string>
*/
protected function categoryFilterOptions(): array
{
return $this->coverageRows()
->pluck('category')
->filter(fn (mixed $category): bool => is_string($category) && $category !== '')
->unique()
->sort()
->mapWithKeys(function (string $category): array {
return [
$category => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $category)->label,
];
})
->all();
}
/**
* @return array<string, string>
*/
protected function restoreFilterOptions(): array
{
return $this->coverageRows()
->pluck('restore')
->filter(fn (mixed $restore): bool => is_string($restore) && $restore !== '')
->unique()
->sort()
->mapWithKeys(function (string $restore): array {
return [
$restore => BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $restore)->label,
];
})
->all();
}
}