454 lines
16 KiB
PHP
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();
|
|
}
|
|
}
|