## 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
217 lines
8.4 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|