574 lines
22 KiB
PHP
574 lines
22 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\Resources\InventoryItemResource;
|
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
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\Inventory\TenantCoverageTruth;
|
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
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\Facades\Gate;
|
|
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';
|
|
|
|
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
->withDefaults(new ActionSurfaceDefaults(
|
|
moreGroupLabel: 'More',
|
|
exportIsDefaultBulkActionForReadOnly: false,
|
|
))
|
|
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory coverage stays read-only and uses KPI widgets instead of header actions.')
|
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
|
|
}
|
|
|
|
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
|
|
->persistFiltersInSession()
|
|
->persistSearchInSession()
|
|
->persistSortInSession()
|
|
->searchable()
|
|
->searchPlaceholder('Search by type or label')
|
|
->defaultSort('follow_up_priority')
|
|
->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('coverage_state')
|
|
->label('Coverage state')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
|
|
->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
|
|
->sortable(),
|
|
TextColumn::make('label')
|
|
->label('Type')
|
|
->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('follow_up_guidance')
|
|
->label('Follow-up guidance')
|
|
->wrap()
|
|
->toggleable(),
|
|
TextColumn::make('observed_item_count')
|
|
->label('Observed items')
|
|
->numeric()
|
|
->sortable(),
|
|
TextColumn::make('category')
|
|
->badge()
|
|
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
|
|
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
|
|
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
|
|
->iconColor(function (?string $state): ?string {
|
|
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
|
|
|
|
return $spec->iconColor ?? $spec->color;
|
|
})
|
|
->toggleable()
|
|
->wrap(),
|
|
TextColumn::make('restore')
|
|
->label('Restore')
|
|
->badge()
|
|
->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;
|
|
})
|
|
->toggleable(),
|
|
IconColumn::make('dependencies')
|
|
->label('Dependencies')
|
|
->boolean()
|
|
->trueIcon('heroicon-m-check-circle')
|
|
->falseIcon('heroicon-m-minus-circle')
|
|
->trueColor('success')
|
|
->falseColor('gray')
|
|
->alignCenter()
|
|
->toggleable(),
|
|
TextColumn::make('type')
|
|
->label('Type key')
|
|
->sortable()
|
|
->fontFamily(FontFamily::Mono)
|
|
->copyable()
|
|
->wrap()
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
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(isToggledHiddenByDefault: true),
|
|
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))
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters($this->tableFilters())
|
|
->emptyStateHeading('No coverage rows match this report')
|
|
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
|
|
->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('coverage_state')
|
|
->label('Coverage state')
|
|
->options([
|
|
'succeeded' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
|
|
'failed' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
|
|
'skipped' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
|
|
'unknown' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
|
|
]),
|
|
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,
|
|
* type: string,
|
|
* segment: string,
|
|
* label: string,
|
|
* category: string,
|
|
* platform: ?string,
|
|
* coverage_state: string,
|
|
* follow_up_required: bool,
|
|
* follow_up_priority: int,
|
|
* follow_up_guidance: string,
|
|
* observed_item_count: int,
|
|
* basis_item_count: ?int,
|
|
* basis_error_code: ?string,
|
|
* restore: ?string,
|
|
* risk: ?string,
|
|
* dependencies: bool,
|
|
* is_basis_payload_backed: bool
|
|
* }>
|
|
*/
|
|
protected function coverageRows(): Collection
|
|
{
|
|
$truth = $this->coverageTruth();
|
|
|
|
if (! $truth instanceof TenantCoverageTruth) {
|
|
return collect();
|
|
}
|
|
|
|
return collect($truth->rows)
|
|
->mapWithKeys(static fn ($row): array => [
|
|
$row->key => $row->toArray(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @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));
|
|
$coverageState = $filters['coverage_state']['value'] ?? null;
|
|
$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($coverageState),
|
|
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
|
|
)
|
|
->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', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
|
|
? $sortColumn
|
|
: null;
|
|
|
|
if ($sortColumn === null) {
|
|
return $rows;
|
|
}
|
|
|
|
$records = $rows->all();
|
|
|
|
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
|
$comparison = match ($sortColumn) {
|
|
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
|
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
|
default => strnatcasecmp(
|
|
(string) ($left[$sortColumn] ?? ''),
|
|
(string) ($right[$sortColumn] ?? ''),
|
|
),
|
|
};
|
|
|
|
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
|
|
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
|
|
}
|
|
|
|
if ($comparison === 0) {
|
|
$comparison = strnatcasecmp(
|
|
(string) ($left['label'] ?? ''),
|
|
(string) ($right['label'] ?? ''),
|
|
);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function coverageSummary(): array
|
|
{
|
|
$truth = $this->coverageTruth();
|
|
|
|
if (! $truth instanceof TenantCoverageTruth) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'supportedTypes' => $truth->supportedTypeCount,
|
|
'succeededTypes' => $truth->succeededTypeCount,
|
|
'followUpTypes' => $truth->followUpTypeCount,
|
|
'observedItems' => $truth->observedItemTotal,
|
|
'observedTypes' => $truth->observedTypeCount(),
|
|
'topFollowUpLabel' => $truth->topPriorityFollowUpRow()?->label,
|
|
'topFollowUpGuidance' => $truth->topPriorityFollowUpRow()?->followUpGuidance,
|
|
'hasCurrentCoverageResult' => $truth->hasCurrentCoverageResult,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function basisRunSummary(): array
|
|
{
|
|
$truth = $this->coverageTruth();
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
if (! $truth->basisRun instanceof OperationRun) {
|
|
return [
|
|
'title' => 'No current coverage basis',
|
|
'body' => $user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
|
|
? 'Run Inventory Sync from Inventory Items to establish current tenant coverage truth.'
|
|
: 'A tenant operator with inventory sync permission must establish current tenant coverage truth.',
|
|
'badgeLabel' => null,
|
|
'badgeColor' => null,
|
|
'runUrl' => null,
|
|
'historyUrl' => null,
|
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
|
];
|
|
}
|
|
|
|
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
|
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
|
|
|
|
return [
|
|
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
|
|
'body' => $canViewRun
|
|
? 'Review the cited inventory sync to inspect provider or permission issues in detail.'
|
|
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
|
'badgeLabel' => $badge->label,
|
|
'badgeColor' => $badge->color,
|
|
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
|
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
|
];
|
|
}
|
|
|
|
protected function coverageTruth(): ?TenantCoverageTruth
|
|
{
|
|
if ($this->cachedCoverageTruth instanceof TenantCoverageTruth) {
|
|
return $this->cachedCoverageTruth;
|
|
}
|
|
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
$this->cachedCoverageTruth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
|
|
|
return $this->cachedCoverageTruth;
|
|
}
|
|
|
|
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
|
{
|
|
return route('admin.operations.index', [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'tableFilters' => [
|
|
'type' => [
|
|
'value' => 'inventory_sync',
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
}
|