|null */ private ?array $authorizedTenants = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.') ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.'); } public function mount(): void { $this->authorizePageAccess(); app(CanonicalAdminTenantFilterState::class)->sync( $this->getTableFiltersSessionKey(), ['status', 'published_state', 'completeness_state'], request(), ); $this->applyRequestedTenantPrefilter(); $this->mountInteractsWithTable(); } protected function getHeaderActions(): array { return [ Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveFilters()) ->action(function (): void { $this->resetTable(); }), ]; } public function table(Table $table): Table { return $table ->query(fn (): Builder => $this->registerQuery()) ->defaultSort('generated_at', 'desc') ->paginated(TablePaginationProfiles::customPage()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')) ->columns([ TextColumn::make('tenant.name')->label('Tenant')->searchable(), TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), TextColumn::make('completeness_state') ->label('Completeness') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('summary.publish_blockers') ->label('Publish blockers') ->formatStateUsing(static function (mixed $state): string { if (! is_array($state) || $state === []) { return '0'; } return (string) count($state); }), ]) ->filters([ SelectFilter::make('tenant_id') ->label('Tenant') ->options(fn (): array => $this->tenantFilterOptions()) ->default(fn (): ?string => $this->defaultTenantFilter()) ->searchable(), SelectFilter::make('status') ->options([ 'draft' => 'Draft', 'ready' => 'Ready', 'published' => 'Published', 'archived' => 'Archived', 'superseded' => 'Superseded', 'failed' => 'Failed', ]), SelectFilter::make('completeness_state') ->label('Completeness') ->options([ 'complete' => 'Complete', 'partial' => 'Partial', 'missing' => 'Missing', 'stale' => 'Stale', ]), SelectFilter::make('published_state') ->label('Published state') ->options([ 'published' => 'Published', 'unpublished' => 'Not published', ]) ->query(function (Builder $query, array $data): Builder { return match ($data['value'] ?? null) { 'published' => $query->whereNotNull('published_at'), 'unpublished' => $query->whereNull('published_at'), default => $query, }; }), FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), ]) ->actions([ Action::make('view_review') ->label('View review') ->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')), Action::make('export_executive_pack') ->label('Export executive pack') ->icon('heroicon-o-arrow-down-tray') ->visible(fn (TenantReview $record): bool => auth()->user() instanceof User && auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant) && in_array($record->status, ['ready', 'published'], true)) ->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)), ]) ->bulkActions([]) ->emptyStateHeading('No review records match this view') ->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.') ->emptyStateActions([ Action::make('clear_filters_empty') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->action(fn (): mixed => $this->resetTable()), ]); } /** * @return array */ public function authorizedTenants(): array { if ($this->authorizedTenants !== null) { return $this->authorizedTenants; } $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User || ! $workspace instanceof Workspace) { return $this->authorizedTenants = []; } return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace); } private function authorizePageAccess(): void { $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User) { abort(403); } if (! $workspace instanceof Workspace) { throw new NotFoundHttpException; } $service = app(TenantReviewRegisterService::class); if (! $service->canAccessWorkspace($user, $workspace)) { throw new NotFoundHttpException; } if ($this->authorizedTenants() === []) { throw new NotFoundHttpException; } } private function registerQuery(): Builder { $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User || ! $workspace instanceof Workspace) { return TenantReview::query()->whereRaw('1 = 0'); } return app(TenantReviewRegisterService::class)->query($user, $workspace); } /** * @return array */ private function tenantFilterOptions(): array { return collect($this->authorizedTenants()) ->mapWithKeys(static fn (Tenant $tenant): array => [ (string) $tenant->getKey() => $tenant->name, ]) ->all(); } private function defaultTenantFilter(): ?string { $tenantId = app(WorkspaceContext::class)->lastTenantId(request()); return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants()) ? (string) $tenantId : null; } private function applyRequestedTenantPrefilter(): void { $requestedTenant = request()->query('tenant'); if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { return; } foreach ($this->authorizedTenants() as $tenant) { if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) { continue; } $this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey(); $this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey(); return; } } private function hasActiveFilters(): bool { $filters = array_filter((array) $this->tableFilters); return $filters !== []; } private function workspace(): ?Workspace { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); return is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null; } }