feat: add inventory coverage interactive table #151

Merged
ahmido merged 1 commits from 124-inventory-coverage-table into dev 2026-03-08 18:33:01 +00:00
15 changed files with 1295 additions and 154 deletions
Showing only changes of commit f71cdb0e64 - Show all commits

View File

@ -48,6 +48,8 @@ ## Active Technologies
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
- PostgreSQL primary app database (123-operations-auto-refresh)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -67,8 +69,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 123-operations-auto-refresh: Added PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail
- 122-empty-state-consistency: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4
- 121-workspace-switch-fix: Added PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4
- 124-inventory-coverage-table: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog`
- 124-inventory-coverage-table: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 124-inventory-coverage-table: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
@ -9,13 +11,32 @@
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\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
class InventoryCoverage extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
protected static ?int $navigationSort = 3;
@ -44,6 +65,11 @@ public static function canAccess(): bool
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
protected function getHeaderWidgets(): array
{
return [
@ -51,35 +77,363 @@ protected function getHeaderWidgets(): array
];
}
/**
* @var array<int, array<string, mixed>>
*/
public array $supportedPolicyTypes = [];
public function table(Table $table): Table
{
return $table
->searchable()
->searchPlaceholder('Search by policy type or label')
->defaultPaginationPageOption(50)
->paginated([25, 50, 'all'])
->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([]);
}
/**
* @var array<int, array<string, mixed>>
* @return array<int, SelectFilter>
*/
public array $foundationTypes = [];
protected function tableFilters(): array
{
$filters = [
SelectFilter::make('category')
->label('Category')
->options($this->categoryFilterOptions()),
];
public function mount(): void
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);
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
->map(function (array $row) use ($resolver): array {
$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'] ?? '');
return array_merge($row, [
'dependencies' => $type !== '' && $resolver->supportsDependencies($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();
}
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
->map(function (array $row): array {
return array_merge($row, [
'dependencies' => false,
]);
/**
* @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();
}

View File

@ -21,7 +21,7 @@ public static function baseline(): self
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.',
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.',

View File

@ -1,133 +1,19 @@
<x-filament::page>
<x-filament-panels::page>
<x-filament::section>
<div class="text-base font-medium">Policies</div>
<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">Dependencies</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 ($supportedPolicyTypes as $row)
@php
$typeSpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyType, $row['type'] ?? null);
$categorySpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
$restoreSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $row['restore'] ?? 'enabled');
$riskSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRisk, $row['risk'] ?? 'n/a');
@endphp
<tr>
<td class="py-2 pr-4">
<span class="font-mono text-xs">{{ $row['type'] ?? '' }}</span>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$typeSpec->color" size="sm">
{{ $typeSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$categorySpec->color" size="sm">
{{ $categorySpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
@if ($row['dependencies'] ?? false)
<x-filament::icon
icon="heroicon-m-check-circle"
class="h-5 w-5 text-success-600 dark:text-success-400"
/>
@else
<x-filament::icon
icon="heroicon-m-minus-circle"
class="h-5 w-5 text-gray-400 dark:text-gray-500"
/>
@endif
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$restoreSpec->color" :icon="$restoreSpec->icon" size="sm">
{{ $restoreSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$riskSpec->color" :icon="$riskSpec->icon" size="sm">
{{ $riskSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Searchable support matrix
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Search by policy type or label, sort the primary columns, and filter the runtime-derived coverage matrix without leaving the tenant inventory workspace.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Coverage rows combine supported policy types and foundations in a single read-only table so Segment and Dependencies stay easy to scan.
</div>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-base font-medium">Foundations</div>
<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">Dependencies</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 ($foundationTypes as $row)
@php
$typeSpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyType, $row['type'] ?? null);
$categorySpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
$restoreSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $row['restore'] ?? 'enabled');
$riskSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRisk, $row['risk'] ?? 'n/a');
@endphp
<tr>
<td class="py-2 pr-4">
<span class="font-mono text-xs">{{ $row['type'] ?? '' }}</span>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$typeSpec->color" size="sm">
{{ $typeSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$categorySpec->color" size="sm">
{{ $categorySpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
@if ($row['dependencies'] ?? false)
<x-filament::icon
icon="heroicon-m-check-circle"
class="h-5 w-5 text-success-600 dark:text-success-400"
/>
@else
<x-filament::icon
icon="heroicon-m-minus-circle"
class="h-5 w-5 text-gray-400 dark:text-gray-500"
/>
@endif
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$restoreSpec->color" :icon="$restoreSpec->icon" size="sm">
{{ $restoreSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$riskSpec->color" :icon="$riskSpec->icon" size="sm">
{{ $riskSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</x-filament::section>
</x-filament::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Inventory Coverage Interactive Table
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-08
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated after first drafting pass; no clarification markers remain.
- The spec keeps framework-specific language only where the product requirement explicitly demands native table behavior, without introducing code-level implementation detail.

View File

@ -0,0 +1,76 @@
openapi: 3.1.0
info:
title: Inventory Coverage Page Contract
version: 1.0.0
description: |
Existing tenant-scoped Filament page contract for the interactive Inventory Coverage table.
This feature does not add a new API endpoint; it formalizes the page route and supported
table query-state parameters used by the Filament table surface.
paths:
/admin/t/{tenant}/inventory/coverage:
get:
summary: Render the tenant-scoped Inventory Coverage page
operationId: getInventoryCoveragePage
tags:
- Inventory Coverage
parameters:
- name: tenant
in: path
required: true
description: Tenant identifier resolved by the existing Filament tenant route binding
schema:
type: string
- name: tableSearch
in: query
required: false
description: Free-text search across policy type and label columns
schema:
type: string
- name: tableFilters[category][value]
in: query
required: false
description: Selected category filter value
schema:
type: string
- name: tableFilters[restore][value]
in: query
required: false
description: Selected restore-mode filter value when restore metadata is available
schema:
type: string
- name: tableSortColumn
in: query
required: false
description: Active table sort column
schema:
type: string
enum:
- type
- label
- name: tableSortDirection
in: query
required: false
description: Active table sort direction
schema:
type: string
enum:
- asc
- desc
- name: page
in: query
required: false
description: Current pagination page for the interactive coverage table
schema:
type: integer
minimum: 1
responses:
'200':
description: Interactive Filament page rendered successfully
content:
text/html:
schema:
type: string
'403':
description: Tenant member lacks the required capability to view the page
'404':
description: Tenant or workspace context is unavailable or the actor is not entitled to the tenant scope

View File

@ -0,0 +1,79 @@
# Data Model: Inventory Coverage Interactive Table
## Overview
This feature does not introduce persistent storage. It defines runtime view-model objects that shape existing coverage metadata into a Filament table.
## Entities
### CoverageTableRow
- Purpose: Represents one row in the interactive coverage table.
- Source: Derived from `InventoryPolicyTypeMeta::supported()` and `InventoryPolicyTypeMeta::foundations()` plus `CoverageCapabilitiesResolver`.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | string | yes | Stable runtime identifier composed from segment and type for table keys and deterministic ordering |
| `segment` | enum(`policy`,`foundation`) | yes | Distinguishes supported policy types from foundation types |
| `type` | string | yes | Stable policy type identifier |
| `label` | string | yes | Human-readable label derived from shared badge metadata |
| `category` | string | yes | Coverage category from the metadata catalog |
| `dependencies` | bool | yes | Whether dependency support is available for the type |
| `restore` | string nullable | no | Restore mode value when present in the current data shape |
| `risk` | string | yes | Risk state from the existing metadata source |
| `source_order` | int | yes | Preserves deterministic fallback ordering from the source lists |
#### Validation Rules
- `key` must be non-empty and unique within the dataset.
- `segment` must be one of `policy` or `foundation`.
- `type` must be non-empty.
- `category` and `risk` must match values already emitted by the metadata source.
- `restore` may be null; if present, it must match a restore-mode value supported by the shared badge catalog.
### CoverageTableDataset
- Purpose: Aggregates the derived rows and the filter metadata required by the table.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `rows` | list<CoverageTableRow> | yes | Full runtime dataset rendered by the table |
| `categories` | list<string> | yes | Distinct category options available for filtering |
| `restore_modes` | list<string> | no | Distinct restore values available for filtering; empty when restore metadata is absent |
#### Relationships
- One `CoverageTableDataset` contains many `CoverageTableRow` objects.
- Badge rendering for each `CoverageTableRow` is derived from shared badge catalogs, not embedded or persisted.
### CoverageTableState
- Purpose: Represents the transient UI state managed by Filament table interactions.
#### Fields
| Field | Type | Description |
|-------|------|-------------|
| `search` | string nullable | Current free-text search term |
| `category_filter` | string nullable | Selected category filter |
| `restore_filter` | string nullable | Selected restore filter when available |
| `sort_column` | string nullable | Current sort column |
| `sort_direction` | enum(`asc`,`desc`) nullable | Current sort direction |
| `page` | int | Current pagination page |
## State Transitions
- Initial load: Build `CoverageTableDataset` from existing metadata sources and render default sort order.
- Search update: Narrow `rows` by matching `type` and `label`.
- Category filter update: Narrow `rows` to selected category.
- Restore filter update: Narrow `rows` to selected restore mode when that filter exists.
- Reset action: Clear search and filters and return to the default dataset view.
## Notes
- No database migrations, model classes, or storage contracts change in this feature.
- The runtime row model exists only to shape existing metadata for a native Filament table experience.

View File

@ -0,0 +1,89 @@
# Implementation Plan: Inventory Coverage Interactive Table
**Branch**: `124-inventory-coverage-table` | **Date**: 2026-03-08 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/124-inventory-coverage-table/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/124-inventory-coverage-table/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Convert the tenant-scoped Inventory Coverage page at `/admin/t/{tenant}/inventory/coverage` from duplicated Blade-rendered static tables into a Filament-native custom-data table backed by the existing inventory policy metadata and coverage capability resolver. The implementation will normalize supported policy types and foundation types into a single runtime dataset, preserve current badge semantics, and add native search, sorting, category filtering, conditional restore-mode filtering, and an explicit zero-results reset path without introducing new storage, routes, or permissions.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog`
**Storage**: N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged
**Testing**: Pest 4 feature/component tests with Filament Livewire table helpers, plus manual dark-mode and badge-regression QA
**Target Platform**: Laravel Sail local development, Filament admin panel, Dokploy-hosted web deployment
**Project Type**: web application
**Performance Goals**: Coverage page render and table interactions stay effectively instantaneous for registry-sized datasets with no external calls and no database writes
**Constraints**: No data-model redesign, no new API surface, no Graph calls, no new RBAC rules, preserve badge semantics, preserve tenant-scoped access, and keep dark-mode parity
**Scale/Scope**: One tenant-scoped Filament page, one interactive table, dozens of runtime-derived coverage rows spanning supported policy types and foundations
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Gate | Pre-Research | Post-Design | Notes |
|------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Read-only inventory coverage metadata only; no backup or snapshot behavior changes. |
| Read/write separation | PASS | PASS | No writes, no destructive actions, no confirmations or audit mutations introduced. |
| Graph contract path | N/A | N/A | No Graph calls or contract-registry changes required. |
| Deterministic capabilities | PASS | PASS | Existing coverage capability resolution stays centralized in existing resolver and metadata catalogs. |
| Workspace + tenant isolation | PASS | PASS | Existing tenant-scoped page access remains unchanged and server-side gated by current membership/capability checks. |
| RBAC-UX authorization semantics | PASS | PASS | No new authorization behavior; implementation keeps current tenant-view gating and avoids raw capability strings. |
| Run observability / Ops-UX | N/A | N/A | No long-running, remote, queued, or scheduled work. |
| Data minimization | PASS | PASS | Page remains registry-derived and read-only; no extra persistence or logging payloads. |
| BADGE-001 centralized badge semantics | PASS | PASS | Existing badge catalogs remain the only source for labels, colors, and icons. |
| Filament Action Surface Contract | PASS WITH EXPLICIT EXEMPTION | PASS WITH EXPLICIT EXEMPTION | Coverage rows are runtime-derived support metadata with no underlying record detail route. The design intentionally omits record inspection and row/bulk actions, and compensates with a first-class searchable/sortable/filterable table plus explicit empty-state reset behavior. |
| UX-001 layout and IA | PASS | PASS | The page remains a Filament page with a native table, obvious filters, zero-result reset CTA, and dark-mode-safe presentation. |
## Implementation Notes
- Restore-mode support is intentionally conditional. The implementation and test suite must use deterministic runtime fixtures for both supported branches: one dataset with restore values so the restore filter is rendered, and one dataset without restore values so the filter is omitted.
## Project Structure
### Documentation (this feature)
```text
specs/124-inventory-coverage-table/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── inventory-coverage-page.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Clusters/Inventory/InventoryCluster.php
│ ├── Pages/InventoryCoverage.php
│ └── Widgets/Inventory/InventoryKpiHeader.php
├── Services/Inventory/CoverageCapabilitiesResolver.php
└── Support/Inventory/
├── InventoryCoverage.php
└── InventoryPolicyTypeMeta.php
resources/
└── views/filament/pages/inventory-coverage.blade.php
tests/
└── Feature/Filament/
├── InventoryPagesTest.php
└── InventoryCoverageTableTest.php
```
**Structure Decision**: Keep the existing single Laravel application structure. Implement the feature inside the current Filament page and Blade view, with no new base directories and with a focused Filament feature test added under `tests/Feature/Filament/`.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Filament table inspection-affordance exemption for coverage rows | Coverage entries are derived from runtime registry metadata rather than first-class records with a natural detail route or resource | Adding a fake detail page or inert row action would add UI ceremony without improving user value, and would conflict with the feature goal of delivering a concise searchable support matrix |

View File

@ -0,0 +1,40 @@
# Quickstart: Inventory Coverage Interactive Table
## Goal
Upgrade the tenant-scoped Inventory Coverage page from duplicated Blade tables to a Filament-native interactive table while preserving existing coverage semantics.
## Implementation Steps
1. Update `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/InventoryCoverage.php` to implement Filament table behavior for custom runtime data.
2. Normalize supported policy types and foundation types into one runtime dataset with a `segment` field and source-derived filter option lists.
3. Define Filament table columns for type, label, category, dependencies, restore mode, risk, and segment context as needed for scanability.
4. Add native search on type and label, sorting on the main type or label column, category filtering, and conditional restore-mode filtering.
5. Replace the raw table markup in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/inventory-coverage.blade.php` with a Filament table render plus concise explanatory framing and an explicit zero-results reset CTA.
6. Add a focused Pest test in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/InventoryCoverageTableTest.php` and keep the existing page-load coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/InventoryPagesTest.php`.
## Verification
### Automated
```bash
vendor/bin/sail up -d
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryPagesTest.php
vendor/bin/sail bin pint --dirty --format agent
```
### Manual
1. Open the tenant-scoped Inventory Coverage page.
2. Search for a known policy type and verify non-matching rows disappear.
3. Apply a category filter and confirm the dataset narrows correctly.
4. If restore metadata exists, apply a restore filter and confirm it narrows correctly.
5. Load or simulate a dataset without restore metadata and verify that no restore filter is exposed.
6. Toggle dark mode and verify text, badges, filters, and empty states remain readable.
7. Compare badge labels, colors, and icons against the previous page semantics.
## Rollback
- Revert the page class, Blade view, and test changes for this feature.
- No database rollback or cache invalidation is required because the feature is read-only and runtime-derived.

View File

@ -0,0 +1,41 @@
# Research: Inventory Coverage Interactive Table
## Decision 1: Use a Filament custom-data table on the existing page
- Decision: Implement the page as a Filament `HasTable` / `InteractsWithTable` page backed by custom runtime records rather than a raw Blade `<table>`.
- Rationale: Filament v5 explicitly supports custom-data tables with search, sort, filters, and pagination via `records()` and a paginator. That matches the current coverage source, which is derived from arrays and resolvers instead of Eloquent models.
- Alternatives considered:
- Keep the Blade table and add ad-hoc Alpine or JavaScript interactions: rejected because it would still sit outside the established Filament product surface and duplicate table behavior the framework already provides.
- Introduce a database-backed coverage model: rejected because the spec explicitly excludes data-model redesign and the current source of truth is already deterministic.
## Decision 2: Normalize supported policy types and foundations into one runtime dataset
- Decision: Build one normalized collection of coverage rows with a `segment` field that distinguishes policy types from foundations.
- Rationale: A single table provides one search box, one filter bar, one sorting model, and one empty-state pattern. The extra segment field preserves the existing semantic distinction without forcing duplicate controls across two separate tables.
- Alternatives considered:
- Keep two separate Filament tables: rejected because it would split search/filter state and make the page feel heavier while still not solving cross-surface discoverability.
- Hide foundations in a secondary collapsible section: rejected because it weakens the current coverage storytelling and makes total support harder to scan.
## Decision 3: Derive filter options from the loaded dataset and show restore filtering only when supported
- Decision: Build category filter options from distinct category values in the normalized dataset and only register a restore-mode filter when at least one row exposes a restore value.
- Rationale: This keeps filters honest to the current data shape, avoids dead controls, and respects the spec requirement to add restore filtering only if that attribute exists.
- Alternatives considered:
- Always show a restore filter with guessed default values: rejected because it would imply data fidelity that the current source may not provide.
- Hardcode category and restore options: rejected because options already live in the metadata catalog and should remain source-driven.
## Decision 4: Preserve badge semantics by reusing existing badge catalogs inside table column rendering
- Decision: Keep all type, category, restore, and risk badge rendering delegated to the existing shared badge catalogs and render them from Filament table columns.
- Rationale: The constitution requires centralized badge semantics. Reusing the existing catalogs keeps labels, colors, and icons aligned with the rest of the application while allowing the page layout to change.
- Alternatives considered:
- Rebuild badges inline inside the table columns: rejected because it would duplicate logic and risk semantic drift.
- Replace badges with plain text for easier testing: rejected because it would degrade the trust and scanability goals of the feature.
## Decision 5: Test the page as a Livewire table surface and keep manual QA only for visual concerns
- Decision: Add a focused Pest feature/component test for the page that uses Filament table helpers to verify page load, search, category filtering, conditional restore filtering, and sorting. Keep dark-mode and badge visual validation as manual QA.
- Rationale: Filament v5 documents `searchTable()`, `filterTable()`, and `sortTable()` for table verification, and the repo already uses these patterns in existing Filament tests. That gives fast regression coverage without promoting a purely visual behavior into brittle snapshot tests.
- Alternatives considered:
- Rely on existing page-load coverage only: rejected because the main risk in this feature is interaction regression, not route availability.
- Browser-test the whole surface: rejected for the initial implementation because the features core interactions are already well covered by Filaments Livewire testing surface.

View File

@ -0,0 +1,122 @@
# Feature Specification: Inventory Coverage Interactive Table
**Feature Branch**: `124-inventory-coverage-table`
**Created**: 2026-03-08
**Status**: Draft
**Input**: User description: "Upgrade inventory coverage from static HTML to an interactive Filament table"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**: `/admin/t/{tenant}/inventory/coverage` tenant-scoped Inventory Coverage page in the Inventory cluster
- **Data Ownership**: Workspace-owned coverage metadata derived from the existing inventory policy type registry and capability resolver, rendered inside a tenant-scoped page without introducing new tenant-owned storage
- **RBAC**: Existing tenant membership and tenant inventory viewing entitlement remain required for page access; this feature does not introduce new permissions or broaden access
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Find Supported Policy Types Quickly (Priority: P1)
As a prospect or tenant admin reviewing product capability, I can search the coverage page by policy type or label and immediately narrow the list to the relevant rows.
**Why this priority**: Fast discovery is the core job of the page. If users cannot find a policy type quickly, the page fails as both a demo surface and an operational lookup tool.
**Independent Test**: Can be fully tested by loading the coverage page with multiple policy types, searching for a partial type or label, and confirming that only matching rows remain while coverage badges stay visible.
**Acceptance Scenarios**:
1. **Given** a tenant user with access to Inventory Coverage and multiple supported policy types, **When** they search by a partial type or label, **Then** the table shows only matching rows.
2. **Given** a search result set, **When** the user clears the search, **Then** the full coverage list returns without losing badge semantics or grouping context.
---
### User Story 2 - Filter Coverage by Meaningful Dimensions (Priority: P2)
As a governance or operations user, I can filter coverage by category and, when present in the current data shape, by restore mode so that I can evaluate support boundaries without manually scanning every row.
**Why this priority**: Filtering is the next most important behavior after search because it turns the page into a practical evaluation tool instead of a static reference sheet.
**Independent Test**: Can be tested by applying a category filter and confirming that only matching rows appear, then verifying that a restore-mode filter is available only when the loaded data contains restore-mode values.
**Acceptance Scenarios**:
1. **Given** coverage rows across multiple categories, **When** the user selects a category filter, **Then** only rows from that category remain visible.
2. **Given** restore-mode data exists in the current row shape, **When** the user selects a restore-mode filter, **Then** only rows with that restore characteristic remain visible.
3. **Given** restore-mode data is not available in the current row shape, **When** the user opens the page, **Then** the page omits that filter rather than showing a broken or misleading control.
---
### User Story 3 - Present Coverage as a Product-Grade Surface (Priority: P3)
As a sales, demo, or evaluation user, I can scan the page as a polished product surface that looks consistent with the rest of the application rather than a raw developer table.
**Why this priority**: This feature is a trust surface. Visual consistency and scanability materially affect confidence during demos and product evaluation, but they depend on the core discovery behaviors above.
**Independent Test**: Can be tested by loading the page in both light and dark modes, confirming that the table uses native product patterns, presents clear empty states, and preserves existing badge semantics.
**Acceptance Scenarios**:
1. **Given** the user opens Inventory Coverage, **When** the page loads, **Then** the coverage content appears in a first-class interactive table experience aligned with other admin list pages.
2. **Given** the page is viewed in dark mode, **When** the user scans the table, filters, and badges, **Then** text, controls, and status cues remain readable and visually consistent.
### Edge Cases
- When a search or filter combination returns no rows, the page shows a specific empty state with a short explanation and exactly one clear action to reset the table state.
- When restore-mode metadata is missing for some or all rows, the page still renders correctly and only exposes a restore-mode filter if the current data shape supports it.
- When both supported policy types and foundation types are shown, users can still understand which rows belong together without duplicating or weakening existing metadata semantics.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is read-only. It introduces no Microsoft Graph calls, no write behavior, no queue or schedule behavior, and no `OperationRun` usage.
**Constitution alignment (RBAC-UX):** This feature stays on the tenant admin plane under the existing tenant-scoped Inventory Coverage page. Authorization behavior does not change: current tenant membership and existing tenant-view capability checks remain the server-side gate, and no new access pathways are introduced.
**Constitution alignment (BADGE-001):** Existing badge semantics for policy type, category, restore mode, risk, and dependency indicators remain centralized in the current badge catalogs or equivalent shared badge mapping. This feature may change presentation layout, but it must not introduce page-local badge labels, colors, or icons.
**Constitution alignment (Filament Action Surfaces):** This feature requires an explicit exemption from the record-inspection affordance portion of the Action Surface Contract. Coverage rows are runtime-derived support metadata, not first-class records with a meaningful detail route, so the page intentionally provides native table search, sort, filters, and an explicit resettable empty state without clickable rows or row actions.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The page remains inside the existing Filament page structure, uses native table interactions for core dimensions, provides a specific empty state with exactly one reset action, and preserves dark-mode readability. No exemption is required.
### Functional Requirements
- **FR-001**: The Inventory Coverage page MUST replace the current raw static HTML tables with a Filament-native interactive table experience.
- **FR-002**: Users MUST be able to search the coverage table by policy type and human-readable label.
- **FR-003**: Users MUST be able to sort by the primary type or name column.
- **FR-004**: Users MUST be able to filter coverage rows by category.
- **FR-005**: Users MUST be able to filter coverage rows by restore mode when that attribute exists in the current data shape.
- **FR-006**: The page MUST preserve the current meaning of badges and status indicators for type, category, restore mode, risk, and dependency support.
- **FR-007**: The page MUST continue using the current coverage data sources and metadata assumptions rather than redefining the inventory coverage model.
- **FR-008**: The page MUST remain visually aligned with other list and table experiences in the application, including dark mode.
- **FR-009**: The page MUST provide an explicit empty state for zero-result searches or filters with exactly one action that returns the user to a populated view.
- **FR-010**: The page MUST remain read-only and MUST NOT add mutation, sync, or comparison behaviors as part of this feature.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Inventory Coverage page | app/Filament/Pages/InventoryCoverage.php | None | Explicit exemption: no inspect affordance because rows are runtime-derived metadata with no meaningful detail route | None | None | Clear filters | None | Not applicable | No | Read-only tenant page; exemption applies only to record inspection, while search/sort/filter and empty-state UX remain required |
### Key Entities *(include if feature involves data)*
- **Coverage Entry**: A single supported policy type or foundation row containing the stable policy type identifier, human-readable label, category, dependency support signal, restore mode, and risk semantics.
- **Coverage Segment**: A logical grouping of coverage entries that distinguishes the supported-policy surface from the foundation surface while still letting users search and filter effectively.
- **Coverage Metadata Catalog**: The shared registry-derived metadata and badge semantics that define how coverage attributes are labeled and visually represented across the application.
## Assumptions
- The existing coverage row shape continues to provide policy type, label, category, dependency support, and risk semantics.
- Restore mode remains optional in the current data shape. Implementation and tests must cover both branches with deterministic runtime fixtures: at least one dataset where restore values exist and the filter is rendered, and one dataset where restore values are absent and the filter is omitted.
- The current tenant-scoped page access rules and Inventory cluster placement remain unchanged.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A user can locate a specific policy type on a populated coverage page in 15 seconds or less using search and table controls.
- **SC-002**: A user can narrow the page to a target category in two interactions or fewer.
- **SC-003**: In manual demo review, a stakeholder can identify restore capability and dependency support for a filtered set of policy types without verbal guidance from the presenter.
- **SC-004**: Manual dark-mode QA finds no unreadable text, broken filter controls, or badge contrast regressions on the coverage page.

View File

@ -0,0 +1,200 @@
# Tasks: Inventory Coverage Interactive Table
**Input**: Design documents from `/specs/124-inventory-coverage-table/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: Tests are REQUIRED for this runtime behavior change. Pest feature/component coverage must verify table rendering, search, filtering, and regression-sensitive presentation.
**Operations**: No `OperationRun` work is required because this feature is read-only and does not introduce long-running, remote, queued, or scheduled behavior.
**RBAC**: No new authorization behavior is introduced. Existing tenant membership and tenant-view capability checks on the page remain the enforcement path.
**Filament UI Action Surfaces**: This feature modifies a Filament page and keeps the explicit inspection-affordance exemption documented in the spec because coverage rows are runtime-derived metadata, not first-class records.
**Filament UI UX-001**: This feature must deliver a native Filament table, an explicit zero-results empty state with exactly one reset CTA, and dark-mode-safe presentation.
**Badges**: Badge semantics must stay centralized through the existing badge catalogs; no ad-hoc mappings are allowed.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Phase 1: Setup (Shared Alignment)
**Purpose**: Confirm implementation boundaries and target files before refactoring the page
- [X] T001 Reconcile the explicit table-surface exemption and page contract in `specs/124-inventory-coverage-table/spec.md` and `specs/124-inventory-coverage-table/contracts/inventory-coverage-page.openapi.yaml` against the final target files `app/Filament/Pages/InventoryCoverage.php` and `resources/views/filament/pages/inventory-coverage.blade.php`
- [X] T002 [P] Review existing coverage page and regression-test touchpoints in `app/Filament/Pages/InventoryCoverage.php`, `resources/views/filament/pages/inventory-coverage.blade.php`, and `tests/Feature/Filament/InventoryPagesTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Introduce the shared table infrastructure that all user stories build on
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [X] T003 Refactor `app/Filament/Pages/InventoryCoverage.php` to implement `HasTable` / `InteractsWithTable` and build a unified runtime coverage dataset from supported policy types and foundation types
- [X] T004 Update `resources/views/filament/pages/inventory-coverage.blade.php` to replace duplicated raw HTML tables with a Filament page shell that renders the native table surface
**Checkpoint**: Foundation ready - search, filters, and polish can now be added to the shared table surface
---
## Phase 3: User Story 1 - Find Supported Policy Types Quickly (Priority: P1) 🎯 MVP
**Goal**: Deliver an interactive coverage table that supports first-class rendering, type-name search, and primary-column sorting
**Independent Test**: Load the Inventory Coverage page as a Livewire table, verify rows render, search by type or label narrows the table correctly, and sorting reorders the main column deterministically
### Tests for User Story 1 ⚠️
- [X] T005 [P] [US1] Create table-rendering, search, and sort tests in `tests/Feature/Filament/InventoryCoverageTableTest.php`
- [X] T006 [P] [US1] Update tenant coverage page-load assertions for the interactive table surface in `tests/Feature/Filament/InventoryPagesTest.php`
### Implementation for User Story 1
- [X] T007 [US1] Implement searchable and sortable primary columns, default ordering, and custom-data pagination in `app/Filament/Pages/InventoryCoverage.php`
- [X] T008 [P] [US1] Add first-load table framing and search-oriented summary copy in `resources/views/filament/pages/inventory-coverage.blade.php`
**Checkpoint**: User Story 1 should now provide a credible searchable coverage table that can be demoed independently
---
## Phase 4: User Story 2 - Filter Coverage by Meaningful Dimensions (Priority: P2)
**Goal**: Add category filtering and conditional restore-mode filtering without changing the current coverage data model
**Independent Test**: Apply a category filter and verify only matching rows remain; when restore metadata exists, apply a restore filter and verify the filtered result set updates correctly; when restore metadata is absent, verify the restore filter is not exposed
### Tests for User Story 2 ⚠️
- [X] T009 [US2] Extend `tests/Feature/Filament/InventoryCoverageTableTest.php` with category-filter assertions plus deterministic restore-mode-present and restore-mode-absent coverage branches
### Implementation for User Story 2
- [X] T010 [US2] Implement source-driven category filter options and conditional restore-mode filter registration in `app/Filament/Pages/InventoryCoverage.php`
- [X] T011 [P] [US2] Add explicit zero-results empty-state copy and a single reset CTA in `resources/views/filament/pages/inventory-coverage.blade.php`
**Checkpoint**: User Story 2 should now let users narrow coverage by the dimensions called out in the spec without weakening existing semantics
---
## Phase 5: User Story 3 - Present Coverage as a Product-Grade Surface (Priority: P3)
**Goal**: Preserve badge semantics and upgrade the page presentation so it reads like a finished Filament product surface in light and dark mode
**Independent Test**: Verify badge labels, colors, icons, and dependency indicators still render from shared semantics; verify the page loads with polished table framing and no dark-mode readability regressions
### Tests for User Story 3 ⚠️
- [X] T012 [P] [US3] Extend `tests/Feature/Filament/InventoryCoverageTableTest.php` with badge and dependency-indicator regression assertions
- [X] T013 [P] [US3] Add interactive-surface regression coverage in `tests/Feature/Filament/InventoryPagesTest.php` for polished page framing and dark-mode-safe markup expectations
### Implementation for User Story 3
- [X] T014 [US3] Move type, category, restore, risk, dependency, and segment presentation into Filament table columns in `app/Filament/Pages/InventoryCoverage.php` using the shared badge catalogs and existing dependency semantics
- [X] T015 [P] [US3] Polish `resources/views/filament/pages/inventory-coverage.blade.php` for enterprise-grade section framing, scannable explanatory copy, and dark-mode-safe spacing around the Filament table
**Checkpoint**: All user stories should now be independently functional, and the page should read as a finished product surface instead of a raw implementation table
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Validate the complete feature and finish cross-story verification
- [X] T016 [P] Run focused verification from `specs/124-inventory-coverage-table/quickstart.md` with `vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php` and `vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryPagesTest.php`
- [X] T017 Run `vendor/bin/sail bin pint --dirty --format agent` after the page and test changes affecting `app/Filament/Pages/InventoryCoverage.php`, `resources/views/filament/pages/inventory-coverage.blade.php`, and `tests/Feature/Filament/`
- [ ] T018 [P] Complete manual dark-mode QA and badge-regression review using `specs/124-inventory-coverage-table/quickstart.md` and record any follow-up fixes before merge
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - blocks all user stories
- **User Stories (Phases 3-5)**: Depend on Foundational completion
- **Polish (Phase 6)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Starts immediately after Foundational and is the MVP slice
- **User Story 2 (P2)**: Starts after Foundational and reuses the shared table infrastructure from Phase 2
- **User Story 3 (P3)**: Starts after Foundational and layers presentation polish on the same table surface
### Within Each User Story
- Tests must be written and fail before implementation changes are considered complete
- `app/Filament/Pages/InventoryCoverage.php` changes land before final view polish when a story spans both files
- Existing regression coverage in `tests/Feature/Filament/InventoryPagesTest.php` stays green while new story-specific assertions are added
### Parallel Opportunities
- `T001` and `T002` can run in parallel once the branch is ready
- `T005` and `T006` can run in parallel because they touch different test files
- `T007` and `T008` can run in parallel after Phase 2 because they touch different files
- `T010` and `T011` can run in parallel after `T009`
- `T012` and `T013` can run in parallel because they touch different test files
- `T014` and `T015` can run in parallel after the test expectations for US3 are established
- `T016` and `T018` can run in parallel once implementation is complete
---
## Parallel Example: User Story 1
```bash
# Launch both regression updates for the MVP slice together:
Task: "T005 [US1] Create table-rendering, search, and sort tests in tests/Feature/Filament/InventoryCoverageTableTest.php"
Task: "T006 [US1] Update tenant coverage page-load assertions in tests/Feature/Filament/InventoryPagesTest.php"
# After the foundation is in place, split code changes across page class and view:
Task: "T007 [US1] Implement searchable and sortable primary columns in app/Filament/Pages/InventoryCoverage.php"
Task: "T008 [US1] Add first-load table framing and search-oriented summary copy in resources/views/filament/pages/inventory-coverage.blade.php"
```
## Parallel Example: User Story 2
```bash
# After filter tests are defined, split page logic and empty-state polish:
Task: "T010 [US2] Implement source-driven category and restore filters in app/Filament/Pages/InventoryCoverage.php"
Task: "T011 [US2] Add zero-results empty-state reset CTA in resources/views/filament/pages/inventory-coverage.blade.php"
```
## Parallel Example: User Story 3
```bash
# Split regression coverage and final polish work across test and UI files:
Task: "T012 [US3] Extend badge and dependency regression assertions in tests/Feature/Filament/InventoryCoverageTableTest.php"
Task: "T013 [US3] Add polished-surface regression coverage in tests/Feature/Filament/InventoryPagesTest.php"
Task: "T014 [US3] Move badge and dependency presentation into Filament table columns in app/Filament/Pages/InventoryCoverage.php"
Task: "T015 [US3] Polish section framing and dark-mode-safe copy in resources/views/filament/pages/inventory-coverage.blade.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational
3. Complete Phase 3: User Story 1
4. Validate table rendering, search, and sort independently
5. Demo the searchable Filament-native coverage table before adding filters and polish
### Incremental Delivery
1. Finish Setup + Foundational to establish the native table surface
2. Add User Story 1 and validate the searchable MVP
3. Add User Story 2 and validate filter behavior independently
4. Add User Story 3 and validate badge semantics and polish independently
5. Finish with focused automated verification, formatting, and manual QA
### Suggested MVP Scope
- Deliver through **Phase 3 / User Story 1** for the first merge candidate
- Defer advanced filtering and final polish until the searchable table foundation is accepted
---
## Notes
- All tasks follow the required checklist format: checkbox, task ID, optional `[P]`, required story label for story phases, and exact file paths
- No database, API, queue, or Graph work is included because the feature is read-only and runtime-derived
- The explicit Filament inspection-affordance exemption stays documented in the spec because coverage rows do not map to navigable records

View File

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use Filament\Facades\Filament;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
function inventoryCoverageRecordKey(string $segment, string $type): string
{
return "{$segment}:{$type}";
}
function inventoryCoverageComponent(User $user, Tenant $tenant): Testable
{
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
test()->actingAs($user);
return Livewire::actingAs($user)->test(InventoryCoverage::class);
}
function removeInventoryCoverageRestoreMetadata(): void
{
config()->set(
'tenantpilot.supported_policy_types',
collect(config('tenantpilot.supported_policy_types', []))
->map(function (array $row): array {
unset($row['restore']);
return $row;
})
->all(),
);
config()->set(
'tenantpilot.foundation_types',
collect(config('tenantpilot.foundation_types', []))
->map(function (array $row): array {
unset($row['restore']);
return $row;
})
->all(),
);
}
it('renders searchable coverage rows for policy and foundation metadata', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
inventoryCoverageComponent($user, $tenant)
->assertOk()
->assertTableColumnExists('type')
->assertTableColumnExists('label')
->assertTableColumnExists('category')
->assertTableColumnExists('dependencies')
->assertCountTableRecords(
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
)
->assertCanSeeTableRecords([$conditionalAccessKey, $scopeTagKey])
->searchTable('conditional')
->assertCanSeeTableRecords([$conditionalAccessKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $scopeTagKey])
->searchTable('Scope Tag')
->assertCanSeeTableRecords([$scopeTagKey])
->assertCanNotSeeTableRecords([$conditionalAccessKey])
->searchTable(null)
->assertCanSeeTableRecords([$conditionalAccessKey, $deviceConfigurationKey, $scopeTagKey]);
});
it('sorts coverage rows by type and label deterministically', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$appProtectionKey = inventoryCoverageRecordKey('policy', 'appProtectionPolicy');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$deviceComplianceKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
$adminTemplatesKey = inventoryCoverageRecordKey('policy', 'groupPolicyConfiguration');
$appConfigDeviceKey = inventoryCoverageRecordKey('policy', 'managedDeviceAppConfiguration');
$appConfigMamKey = inventoryCoverageRecordKey('policy', 'mamAppConfiguration');
inventoryCoverageComponent($user, $tenant)
->sortTable('type')
->assertCanSeeTableRecords([$appProtectionKey, $conditionalAccessKey, $deviceComplianceKey], inOrder: true)
->sortTable('label')
->assertCanSeeTableRecords([$adminTemplatesKey, $appConfigDeviceKey, $appConfigMamKey], inOrder: true);
});
it('filters coverage rows by category and restore mode when restore metadata exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$securityBaselineKey = inventoryCoverageRecordKey('policy', 'securityBaselinePolicy');
inventoryCoverageComponent($user, $tenant)
->assertTableFilterExists('category')
->assertTableFilterExists('restore')
->filterTable('category', 'Foundations')
->assertCanSeeTableRecords([$assignmentFilterKey, $scopeTagKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $conditionalAccessKey])
->removeTableFilters()
->filterTable('restore', 'preview-only')
->assertCanSeeTableRecords([$conditionalAccessKey, $securityBaselineKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $assignmentFilterKey]);
});
it('omits the restore filter when the runtime dataset has no restore metadata', function (): void {
removeInventoryCoverageRestoreMetadata();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$component = inventoryCoverageComponent($user, $tenant)
->assertTableFilterExists('category');
expect($component->instance()->getTable()->getFilter('restore'))->toBeNull();
});
it('shows a single clear-filters empty state action and can reset back to a populated dataset', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
inventoryCoverageComponent($user, $tenant)
->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
->searchTable('no-such-coverage-entry')
->assertCountTableRecords(0)
->assertSee('No coverage entries match this view')
->assertSee('Clear filters')
->searchTable(null)
->assertCountTableRecords(
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
)
->assertCanSeeTableRecords([$conditionalAccessKey]);
});
it('preserves badge semantics and dependency indicators in the interactive table columns', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'conditionalAccessPolicy');
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Conditional Access');
$restoreSpec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, 'preview-only');
$riskSpec = BadgeCatalog::spec(BadgeDomain::PolicyRisk, 'high');
inventoryCoverageComponent($user, $tenant)
->assertTableColumnFormattedStateSet('label', $typeSpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('category', $categorySpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('restore', $restoreSpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('risk', $riskSpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('segment', 'Policy', $conditionalAccessKey)
->assertTableColumnFormattedStateSet('segment', 'Foundation', $assignmentFilterKey)
->assertTableColumnStateSet('dependencies', true, $deviceConfigurationKey)
->assertTableColumnStateSet('dependencies', false, $assignmentFilterKey)
->assertTableColumnExists('label', function (TextColumn $column) use ($typeSpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $typeSpec->color
&& $column->getIcon($state) === $typeSpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('category', function (TextColumn $column) use ($categorySpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $categorySpec->color
&& $column->getIcon($state) === $categorySpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('restore', function (TextColumn $column) use ($restoreSpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $restoreSpec->color
&& $column->getIcon($state) === $restoreSpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('risk', function (TextColumn $column) use ($riskSpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $riskSpec->color
&& $column->getIcon($state) === $riskSpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('dependencies', function (IconColumn $column): bool {
$state = $column->getState();
return $state === true
&& $column->getColor($state) === 'success'
&& (string) $column->getIcon($state) === 'heroicon-m-check-circle';
}, $deviceConfigurationKey)
->assertTableColumnExists('dependencies', function (IconColumn $column): bool {
$state = $column->getState();
return $state === false
&& $column->getColor($state) === 'gray'
&& (string) $column->getIcon($state) === 'heroicon-m-minus-circle';
}, $assignmentFilterKey);
});

View File

@ -41,7 +41,7 @@
$this->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk()
->assertSee('Policies');
->assertSee('Searchable support matrix');
});
Bus::assertNothingDispatched();

View File

@ -55,7 +55,9 @@
->assertSee($itemsUrl)
->assertSee($kpiLabels)
->assertSee('Coverage')
->assertSee('Policies')
->assertSee('Foundations')
->assertSee('Searchable support matrix')
->assertSee('Search by policy type or label')
->assertSee('Coverage rows')
->assertSee('Segment')
->assertSee('Dependencies');
});