TenantAtlas/app/Livewire/BaselineCompareEvidenceGapTable.php
ahmido c17255f854 feat: implement baseline subject resolution semantics (#193)
## Summary
- add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories
- persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract
- add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`

## Notes
- verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape
- excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00

217 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class BaselineCompareEvidenceGapTable extends TableComponent
{
/**
* @var list<array<string, mixed>>
*/
public array $gapRows = [];
public string $context = 'default';
/**
* @param list<array<string, mixed>> $buckets
*/
public function mount(array $buckets = [], string $context = 'default'): void
{
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
$this->context = $context;
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
->defaultSort('reason_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->searchable()
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->filterRows(
rows: collect($this->gapRows),
search: $search,
filters: $filters,
);
$rows = $this->sortRows(
rows: $rows,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
);
return $this->paginateRows(
rows: $rows,
page: $page,
recordsPerPage: $recordsPerPage,
);
})
->filters([
SelectFilter::make('reason_code')
->label(__('baseline-compare.evidence_gap_reason'))
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
SelectFilter::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
SelectFilter::make('subject_class')
->label(__('baseline-compare.evidence_gap_subject_class'))
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
SelectFilter::make('operator_action_category')
->label(__('baseline-compare.evidence_gap_next_action'))
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
])
->striped()
->deferLoading(! app()->runningUnitTests())
->columns([
TextColumn::make('reason_label')
->label(__('baseline-compare.evidence_gap_reason'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_class_label')
->label(__('baseline-compare.evidence_gap_subject_class'))
->badge()
->searchable()
->sortable()
->wrap(),
TextColumn::make('resolution_outcome_label')
->label(__('baseline-compare.evidence_gap_outcome'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('operator_action_category_label')
->label(__('baseline-compare.evidence_gap_next_action'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_key')
->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable()
->sortable()
->wrap()
->extraAttributes(['class' => 'font-mono text-xs']),
])
->actions([])
->bulkActions([])
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
}
public function render(): View
{
return view('livewire.baseline-compare-evidence-gap-table');
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @param array<string, mixed> $filters
* @return Collection<int, array<string, mixed>>
*/
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$reasonCode = $filters['reason_code']['value'] ?? null;
$policyType = $filters['policy_type']['value'] ?? null;
$subjectClass = $filters['subject_class']['value'] ?? null;
$operatorActionCategory = $filters['operator_action_category']['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['search_text'] ?? '')), $normalizedSearch);
});
}
)
->when(
filled($reasonCode),
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
)
->when(
filled($policyType),
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
)
->when(
filled($subjectClass),
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
)
->when(
filled($operatorActionCategory),
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @return Collection<int, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
if (! filled($sortColumn)) {
return $rows;
}
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
return $rows->sortBy(
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
SORT_NATURAL | SORT_FLAG_CASE,
$direction === 'desc'
)->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
$perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page);
$total = $rows->count();
$items = $rows->forPage($currentPage, $perPage)->values();
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
);
}
}