|null */ private ?array $authorizedTenants = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) ->withDefaults(new ActionSurfaceDefaults( moreGroupLabel: 'More', exportIsDefaultBulkActionForReadOnly: false, )) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.'); } public static function canAccess(): bool { if (Filament::getCurrentPanel()?->getId() !== 'admin') { return false; } $user = auth()->user(); if (! $user instanceof User) { return false; } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if (! is_int($workspaceId)) { return false; } $workspace = Workspace::query()->whereKey($workspaceId)->first(); if (! $workspace instanceof Workspace) { return false; } /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); return $resolver->isMember($user, $workspace) && $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE); } public function mount(): void { $this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null; $this->mountInteractsWithTable(); $this->applyRequestedTenantPrefilter(); if ($this->selectedFindingExceptionId !== null) { $this->selectedFindingException(); } } protected function getHeaderActions(): array { $actions = app(OperateHubShell::class)->headerActions( scopeActionName: 'operate_hub_scope_finding_exceptions', returnActionName: 'operate_hub_return_finding_exceptions', ); $actions[] = Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveQueueFilters()) ->action(function (): void { $this->removeTableFilter('tenant_id'); $this->removeTableFilter('status'); $this->removeTableFilter('current_validity_state'); $this->selectedFindingExceptionId = null; $this->resetTable(); }); $actions[] = Action::make('view_tenant_register') ->label('View tenant register') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->visible(fn (): bool => $this->filteredTenant() instanceof Tenant) ->url(function (): ?string { $tenant = $this->filteredTenant(); if (! $tenant instanceof Tenant) { return null; } return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant); }); $actions[] = Action::make('clear_selected_exception') ->label('Close details') ->color('gray') ->visible(fn (): bool => $this->selectedFindingExceptionId !== null) ->action(function (): void { $this->selectedFindingExceptionId = null; }); $actions[] = Action::make('open_selected_exception') ->label('Open tenant detail') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->visible(fn (): bool => $this->selectedFindingExceptionId !== null) ->url(fn (): ?string => $this->selectedExceptionUrl()); $actions[] = Action::make('open_selected_finding') ->label('Open finding') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->visible(fn (): bool => $this->selectedFindingExceptionId !== null) ->url(fn (): ?string => $this->selectedFindingUrl()); $actions[] = Action::make('approve_selected_exception') ->label('Approve exception') ->color('success') ->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false) ->requiresConfirmation() ->form([ DateTimePicker::make('effective_from') ->label('Effective from') ->required() ->seconds(false), DateTimePicker::make('expires_at') ->label('Expires at') ->required() ->seconds(false), Textarea::make('approval_reason') ->label('Approval reason') ->rows(3) ->maxLength(2000), ]) ->action(function (array $data, FindingExceptionService $service): void { $record = $this->selectedFindingException(); $user = auth()->user(); if (! $record instanceof FindingException || ! $user instanceof User) { abort(404); } $wasRenewalRequest = $record->isPendingRenewal(); $updated = $service->approve($record, $user, $data); $this->selectedFindingExceptionId = (int) $updated->getKey(); $this->resetTable(); Notification::make() ->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved') ->success() ->send(); }); $actions[] = Action::make('reject_selected_exception') ->label('Reject exception') ->color('danger') ->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false) ->requiresConfirmation() ->form([ Textarea::make('rejection_reason') ->label('Rejection reason') ->rows(3) ->required() ->maxLength(2000), ]) ->action(function (array $data, FindingExceptionService $service): void { $record = $this->selectedFindingException(); $user = auth()->user(); if (! $record instanceof FindingException || ! $user instanceof User) { abort(404); } $wasRenewalRequest = $record->isPendingRenewal(); $updated = $service->reject($record, $user, $data); $this->selectedFindingExceptionId = (int) $updated->getKey(); $this->resetTable(); Notification::make() ->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected') ->success() ->send(); }); return $actions; } public function table(Table $table): Table { return $table ->query(fn (): Builder => $this->queueBaseQuery()) ->defaultSort('requested_at', 'asc') ->paginated(TablePaginationProfiles::customPage()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->columns([ TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus)) ->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)), TextColumn::make('current_validity_state') ->label('Validity') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity)) ->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)), TextColumn::make('tenant.name') ->label('Tenant') ->searchable(), TextColumn::make('finding_summary') ->label('Finding') ->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id) ->searchable(), TextColumn::make('requester.name') ->label('Requested by') ->placeholder('—'), TextColumn::make('owner.name') ->label('Owner') ->placeholder('—'), TextColumn::make('review_due_at') ->label('Review due') ->dateTime() ->placeholder('—') ->sortable(), TextColumn::make('expires_at') ->label('Expires') ->dateTime() ->placeholder('—') ->sortable(), TextColumn::make('requested_at') ->label('Requested') ->dateTime() ->sortable(), ]) ->filters([ SelectFilter::make('tenant_id') ->label('Tenant') ->options(fn (): array => $this->tenantFilterOptions()) ->searchable(), SelectFilter::make('status') ->options(FilterOptionCatalog::findingExceptionStatuses()), SelectFilter::make('current_validity_state') ->label('Validity') ->options(FilterOptionCatalog::findingExceptionValidityStates()), ]) ->actions([ Action::make('inspect_exception') ->label('Inspect exception') ->icon('heroicon-o-eye') ->color('gray') ->action(function (FindingException $record): void { $this->selectedFindingExceptionId = (int) $record->getKey(); }), ]) ->bulkActions([]) ->emptyStateHeading('No exceptions match this queue') ->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.') ->emptyStateIcon('heroicon-o-shield-check') ->emptyStateActions([ Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->action(function (): void { $this->removeTableFilter('tenant_id'); $this->removeTableFilter('status'); $this->removeTableFilter('current_validity_state'); $this->selectedFindingExceptionId = null; $this->resetTable(); }), ]); } public function selectedFindingException(): ?FindingException { if (! is_int($this->selectedFindingExceptionId)) { return null; } $record = $this->queueBaseQuery() ->whereKey($this->selectedFindingExceptionId) ->first(); if (! $record instanceof FindingException) { throw new NotFoundHttpException; } return $record; } public function selectedExceptionUrl(): ?string { $record = $this->selectedFindingException(); if (! $record instanceof FindingException || ! $record->tenant) { return null; } return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant); } public function selectedFindingUrl(): ?string { $record = $this->selectedFindingException(); if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) { return null; } return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant); } /** * @return array */ public function authorizedTenants(): array { if ($this->authorizedTenants !== null) { return $this->authorizedTenants; } $user = auth()->user(); if (! $user instanceof User) { return $this->authorizedTenants = []; } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if (! is_int($workspaceId)) { return $this->authorizedTenants = []; } $tenants = $user->tenants() ->where('tenants.workspace_id', $workspaceId) ->orderBy('tenants.name') ->get(); return $this->authorizedTenants = $tenants ->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId) ->values() ->all(); } private function queueBaseQuery(): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $tenantIds = array_values(array_map( static fn (Tenant $tenant): int => (int) $tenant->getKey(), $this->authorizedTenants(), )); return FindingException::query() ->with([ 'tenant', 'requester', 'owner', 'approver', 'finding' => fn ($query) => $query->withSubjectDisplayName(), 'decisions.actor', 'evidenceReferences', ]) ->where('workspace_id', (int) $workspaceId) ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds); } /** * @return array */ private function tenantFilterOptions(): array { return Collection::make($this->authorizedTenants()) ->mapWithKeys(static fn (Tenant $tenant): array => [ (string) $tenant->getKey() => $tenant->name, ]) ->all(); } 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 filteredTenant(): ?Tenant { $tenantId = $this->currentTenantFilterId(); if (! is_int($tenantId)) { return null; } foreach ($this->authorizedTenants() as $tenant) { if ((int) $tenant->getKey() === $tenantId) { return $tenant; } } return null; } private function currentTenantFilterId(): ?int { $tenantFilter = data_get($this->tableFilters, 'tenant_id.value'); if (! is_numeric($tenantFilter)) { $tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value'); } return is_numeric($tenantFilter) ? (int) $tenantFilter : null; } private function hasActiveQueueFilters(): bool { return $this->currentTenantFilterId() !== null || is_string(data_get($this->tableFilters, 'status.value')) || is_string(data_get($this->tableFilters, 'current_validity_state.value')); } }