> */ public array $rows = []; /** * @var array|null */ private ?array $accessibleTenants = null; private ?Collection $cachedSnapshots = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) ->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.'); } public function mount(): void { $this->authorizeWorkspaceAccess(); $this->seedTableStateFromQuery(); $this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all(); $this->mountInteractsWithTable(); } public function table(Table $table): Table { return $table ->defaultSort('tenant_name') ->defaultPaginationPageOption(25) ->paginated(TablePaginationProfiles::customPage()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->searchable() ->searchPlaceholder('Search tenant or next step') ->records(function ( ?string $sortColumn, ?string $sortDirection, ?string $search, array $filters, int $page, int $recordsPerPage ): LengthAwarePaginator { $rows = $this->rowsForState($filters, $search); $rows = $this->sortRows($rows, $sortColumn, $sortDirection); return $this->paginateRows($rows, $page, $recordsPerPage); }) ->filters([ SelectFilter::make('tenant_id') ->label('Tenant') ->options(fn (): array => $this->tenantFilterOptions()) ->searchable(), ]) ->columns([ TextColumn::make('tenant_name') ->label('Tenant') ->sortable(), TextColumn::make('artifact_truth_label') ->label('Artifact truth') ->badge() ->color(fn (array $record): string => (string) ($record['artifact_truth_color'] ?? 'gray')) ->icon(fn (array $record): ?string => is_string($record['artifact_truth_icon'] ?? null) ? $record['artifact_truth_icon'] : null) ->description(fn (array $record): ?string => is_string($record['artifact_truth_explanation'] ?? null) ? $record['artifact_truth_explanation'] : null) ->sortable() ->wrap(), TextColumn::make('freshness_label') ->label('Freshness') ->badge() ->color(fn (array $record): string => (string) ($record['freshness_color'] ?? 'gray')) ->icon(fn (array $record): ?string => is_string($record['freshness_icon'] ?? null) ? $record['freshness_icon'] : null) ->sortable(), TextColumn::make('generated_at') ->label('Generated') ->placeholder('—') ->sortable(), TextColumn::make('missing_dimensions') ->label('Not collected yet') ->numeric() ->sortable(), TextColumn::make('stale_dimensions') ->label('Refresh recommended') ->numeric() ->sortable(), TextColumn::make('next_step') ->label('Next step') ->wrap(), ]) ->recordUrl(fn ($record): ?string => is_array($record) ? (is_string($record['view_url'] ?? null) ? $record['view_url'] : null) : null) ->actions([]) ->bulkActions([]) ->emptyStateHeading('No evidence snapshots in this scope') ->emptyStateDescription(fn (): string => $this->hasActiveOverviewFilters() ? 'Clear the current filters to return to the full workspace evidence overview.' : 'Adjust filters or create a tenant snapshot to populate the workspace overview.') ->emptyStateActions([ Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveOverviewFilters()) ->action(fn (): mixed => $this->clearOverviewFilters()), ]); } /** * @return array */ protected function getHeaderActions(): array { return [ Action::make('clear_filters') ->label('Clear filters') ->color('gray') ->visible(fn (): bool => $this->hasActiveOverviewFilters()) ->action(fn (): mixed => $this->clearOverviewFilters()), ]; } public function clearOverviewFilters(): void { $this->tableFilters = [ 'tenant_id' => ['value' => null], ]; $this->tableDeferredFilters = $this->tableFilters; $this->tableSearch = ''; $this->rows = $this->rowsForState($this->tableFilters, $this->tableSearch)->values()->all(); session()->put($this->getTableFiltersSessionKey(), $this->tableFilters); session()->put($this->getTableSearchSessionKey(), $this->tableSearch); $this->resetPage(); } private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope { $presenter = app(ArtifactTruthPresenter::class); return $fresh ? $presenter->forEvidenceSnapshotFresh($snapshot) : $presenter->forEvidenceSnapshot($snapshot); } private function authorizeWorkspaceAccess(): void { $user = auth()->user(); if (! $user instanceof User) { throw new AuthenticationException; } app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request()); } /** * @return array */ private function accessibleTenants(): array { if (is_array($this->accessibleTenants)) { return $this->accessibleTenants; } $user = auth()->user(); if (! $user instanceof User) { return $this->accessibleTenants = []; } $workspaceId = $this->workspaceId(); return $this->accessibleTenants = $user->tenants() ->where('tenants.workspace_id', $workspaceId) ->orderBy('tenants.name') ->get() ->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant)) ->values() ->all(); } /** * @return array */ private function tenantFilterOptions(): array { return collect($this->accessibleTenants()) ->mapWithKeys(static fn (Tenant $tenant): array => [ (string) $tenant->getKey() => $tenant->name, ]) ->all(); } /** * @param array $filters * @return Collection> */ private function rowsForState(array $filters = [], ?string $search = null): Collection { $rows = $this->baseRows(); $tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value')); $normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch))); if ($tenantFilter !== null) { $rows = $rows->where('tenant_id', $tenantFilter); } if ($normalizedSearch === '') { return $rows; } return $rows->filter(function (array $row) use ($normalizedSearch): bool { $haystack = implode(' ', [ (string) ($row['tenant_name'] ?? ''), (string) ($row['artifact_truth_label'] ?? ''), (string) ($row['artifact_truth_explanation'] ?? ''), (string) ($row['freshness_label'] ?? ''), (string) ($row['next_step'] ?? ''), ]); return str_contains(Str::lower($haystack), $normalizedSearch); }); } /** * @return Collection> */ private function baseRows(): Collection { $snapshots = $this->latestAccessibleSnapshots(); $currentReviewTenantIds = $this->currentReviewTenantIds($snapshots); return $snapshots->mapWithKeys(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array { return [(string) $snapshot->getKey() => $this->rowForSnapshot($snapshot, $currentReviewTenantIds)]; }); } /** * @return Collection */ private function latestAccessibleSnapshots(): Collection { if ($this->cachedSnapshots instanceof Collection) { return $this->cachedSnapshots; } $tenantIds = collect($this->accessibleTenants()) ->map(static fn (Tenant $tenant): int => (int) $tenant->getKey()) ->all(); $query = EvidenceSnapshot::query() ->with('tenant') ->where('workspace_id', $this->workspaceId()) ->where('status', 'active') ->latest('generated_at'); if ($tenantIds === []) { $query->whereRaw('1 = 0'); } else { $query->whereIn('tenant_id', $tenantIds); } return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values(); } /** * @param Collection $snapshots * @return array */ private function currentReviewTenantIds(Collection $snapshots): array { return TenantReview::query() ->where('workspace_id', $this->workspaceId()) ->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all()) ->whereIn('status', [ TenantReviewStatus::Draft->value, TenantReviewStatus::Ready->value, TenantReviewStatus::Published->value, ]) ->pluck('tenant_id') ->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true]) ->all(); } /** * @param array $currentReviewTenantIds * @return array */ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array { $truth = $this->snapshotTruth($snapshot); $freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState); $tenantId = (int) $snapshot->tenant_id; $hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false; $nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current' ? 'Create a current review from this evidence snapshot' : $truth->nextStepText(); return [ 'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant', 'tenant_id' => $tenantId, 'snapshot_id' => (int) $snapshot->getKey(), 'generated_at' => $snapshot->generated_at?->toDateTimeString(), 'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0), 'stale_dimensions' => (int) ($snapshot->summary['stale_dimensions'] ?? 0), 'artifact_truth_label' => $truth->primaryLabel, 'artifact_truth_color' => $truth->primaryBadgeSpec()->color, 'artifact_truth_icon' => $truth->primaryBadgeSpec()->icon, 'artifact_truth_explanation' => $truth->primaryExplanation, 'artifact_truth' => [ 'label' => $truth->primaryLabel, 'color' => $truth->primaryBadgeSpec()->color, 'icon' => $truth->primaryBadgeSpec()->icon, 'explanation' => $truth->primaryExplanation, ], 'freshness_label' => $freshnessSpec->label, 'freshness_color' => $freshnessSpec->color, 'freshness_icon' => $freshnessSpec->icon, 'freshness' => [ 'label' => $freshnessSpec->label, 'color' => $freshnessSpec->color, 'icon' => $freshnessSpec->icon, ], 'next_step' => $nextStep, 'view_url' => $snapshot->tenant ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) : null, ]; } /** * @param Collection> $rows * @return Collection> */ private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection { $sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true) ? $sortColumn : 'tenant_name'; $descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc'; $records = $rows->all(); uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int { $comparison = in_array($sortColumn, ['missing_dimensions', 'stale_dimensions'], true) ? ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)) : strnatcasecmp((string) ($left[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? '')); if ($comparison === 0) { $comparison = strnatcasecmp((string) ($left['tenant_name'] ?? ''), (string) ($right['tenant_name'] ?? '')); } return $descending ? ($comparison * -1) : $comparison; }); return collect($records); } /** * @param Collection> $rows */ private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator { return new LengthAwarePaginator( items: $rows->forPage($page, $recordsPerPage), total: $rows->count(), perPage: $recordsPerPage, currentPage: $page, ); } private function seedTableStateFromQuery(): void { $query = request()->query(); if (array_key_exists('search', $query)) { $this->tableSearch = trim((string) request()->query('search', '')); } if (! array_key_exists('tenant_id', $query)) { return; } $tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id')); if ($tenantFilter === null) { return; } $this->tableFilters = [ 'tenant_id' => ['value' => (string) $tenantFilter], ]; $this->tableDeferredFilters = $this->tableFilters; } private function normalizeTenantFilter(mixed $value): ?int { if (! is_numeric($value)) { return null; } $requestedTenantId = (int) $value; $allowedTenantIds = collect($this->accessibleTenants()) ->map(static fn (Tenant $tenant): int => (int) $tenant->getKey()) ->all(); return in_array($requestedTenantId, $allowedTenantIds, true) ? $requestedTenantId : null; } private function hasActiveOverviewFilters(): bool { return filled(data_get($this->tableFilters, 'tenant_id.value')) || trim((string) $this->tableSearch) !== ''; } private function workspaceId(): int { $user = auth()->user(); if (! $user instanceof User) { throw new AuthenticationException; } return (int) app(WorkspaceContext::class) ->currentWorkspaceForMemberOrFail($user, request()) ->getKey(); } }