|null */ private ?array $authorizedTenants = null; /** * @var array|null */ private ?array $visibleTenants = null; private ?Workspace $workspace = null; public string $queueView = 'unassigned'; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) ->withListRowPrimaryActionLimit(1) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the shared-unassigned scope fixed and expose only a tenant-prefilter clear action when needed.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The intake queue keeps Claim finding inline and does not render a secondary More menu on rows.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The intake queue does not expose bulk actions in v1.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state stays calm and offers exactly one recovery CTA per branch.') ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page while Claim finding remains the only inline safe shortcut.'); } public function mount(): void { $this->queueView = $this->resolveRequestedQueueView(); $this->authorizePageAccess(); app(CanonicalAdminTenantFilterState::class)->sync( $this->getTableFiltersSessionKey(), [], request(), ); $this->applyRequestedTenantPrefilter(); $this->mountInteractsWithTable(); $this->normalizeTenantFilterState(); } protected function getHeaderActions(): array { return [ Action::make('clear_tenant_filter') ->label('Clear tenant filter') ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->action(fn (): mixed => $this->clearTenantFilter()), ]; } public function table(Table $table): Table { return $table ->query(fn (): Builder => $this->queueViewQuery()) ->paginated(TablePaginationProfiles::customPage()) ->persistFiltersInSession() ->columns([ TextColumn::make('tenant.name') ->label('Tenant'), TextColumn::make('subject_display_name') ->label('Finding') ->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey()) ->description(fn (Finding $record): ?string => $this->ownerContext($record)) ->wrap(), TextColumn::make('severity') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity)) ->color(BadgeRenderer::color(BadgeDomain::FindingSeverity)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus)) ->color(BadgeRenderer::color(BadgeDomain::FindingStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)) ->description(fn (Finding $record): ?string => $this->reopenedCue($record)), TextColumn::make('due_at') ->label('Due') ->dateTime() ->placeholder('—') ->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)), TextColumn::make('intake_reason') ->label('Queue reason') ->badge() ->state(fn (Finding $record): string => $this->queueReason($record)) ->color(fn (Finding $record): string => $this->queueReasonColor($record)), ]) ->filters([ SelectFilter::make('tenant_id') ->label('Tenant') ->options(fn (): array => $this->tenantFilterOptions()) ->searchable(), ]) ->actions([ $this->claimAction(), ]) ->bulkActions([]) ->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record)) ->emptyStateHeading(fn (): string => $this->emptyState()['title']) ->emptyStateDescription(fn (): string => $this->emptyState()['body']) ->emptyStateIcon(fn (): string => $this->emptyState()['icon']) ->emptyStateActions($this->emptyStateActions()); } /** * @return array */ public function appliedScope(): array { $tenant = $this->filteredTenant(); $queueView = $this->currentQueueView(); return [ 'workspace_scoped' => true, 'fixed_scope' => 'visible_unassigned_open_findings_only', 'queue_view' => $queueView, 'queue_view_label' => $this->queueViewLabel($queueView), 'tenant_prefilter_source' => $this->tenantPrefilterSource(), 'tenant_label' => $tenant?->name, ]; } /** * @return array> */ public function queueViews(): array { $queueView = $this->currentQueueView(); return [ [ 'key' => 'unassigned', 'label' => 'Unassigned', 'fixed' => true, 'active' => $queueView === 'unassigned', 'badge_count' => (clone $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false))->count(), 'url' => $this->queueUrl(['view' => null]), ], [ 'key' => 'needs_triage', 'label' => 'Needs triage', 'fixed' => true, 'active' => $queueView === 'needs_triage', 'badge_count' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(), 'url' => $this->queueUrl(['view' => 'needs_triage']), ], ]; } /** * @return array{visible_unassigned: int, visible_needs_triage: int, visible_overdue: int} */ public function summaryCounts(): array { $visibleQuery = $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false); return [ 'visible_unassigned' => (clone $visibleQuery)->count(), 'visible_needs_triage' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(), 'visible_overdue' => (clone $visibleQuery) ->whereNotNull('due_at') ->where('due_at', '<', now()) ->count(), ]; } /** * @return array */ public function emptyState(): array { if ($this->tenantFilterAloneExcludesRows()) { return [ 'title' => 'No intake findings match this tenant scope', 'body' => 'Your current tenant filter is hiding shared intake work that is still visible elsewhere in this workspace.', 'icon' => 'heroicon-o-funnel', 'action_name' => 'clear_tenant_filter_empty', 'action_label' => 'Clear tenant filter', 'action_kind' => 'clear_tenant_filter', ]; } return [ 'title' => 'Shared intake is clear', 'body' => 'No visible unassigned findings currently need first routing across your entitled tenants. Open your personal queue if you want to continue with claimed work.', 'icon' => 'heroicon-o-inbox-stack', 'action_name' => 'open_my_findings_empty', 'action_label' => 'Open my findings', 'action_kind' => 'url', 'action_url' => MyFindingsInbox::getUrl(panel: 'admin'), ]; } public function updatedTableFilters(): void { $this->normalizeTenantFilterState(); } public function clearTenantFilter(): void { $this->removeTableFilter('tenant_id'); $this->resetTable(); } /** * @return array */ public function visibleTenants(): array { if ($this->visibleTenants !== null) { return $this->visibleTenants; } $user = auth()->user(); $tenants = $this->authorizedTenants(); if (! $user instanceof User || $tenants === []) { return $this->visibleTenants = []; } $resolver = app(CapabilityResolver::class); $resolver->primeMemberships( $user, array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants), ); return $this->visibleTenants = array_values(array_filter( $tenants, fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW), )); } private function claimAction(): Action { return UiEnforcement::forTableAction( Action::make('claim') ->label('Claim finding') ->icon('heroicon-o-user-plus') ->color('gray') ->visible(fn (Finding $record): bool => $record->assignee_user_id === null && in_array((string) $record->status, Finding::openStatuses(), true)) ->requiresConfirmation() ->modalHeading('Claim finding') ->modalDescription(function (?Finding $record = null): string { $findingLabel = $record?->resolvedSubjectDisplayName() ?? ($record instanceof Finding ? 'Finding #'.$record->getKey() : 'this finding'); $tenantLabel = $record?->tenant?->name ?? 'this tenant'; return sprintf( 'Claim "%s" in %s? It will move into your personal queue, while the accountable owner and lifecycle state stay unchanged.', $findingLabel, $tenantLabel, ); }) ->modalSubmitActionLabel('Claim finding') ->action(function (Finding $record): void { $tenant = $record->tenant; $user = auth()->user(); if (! $tenant instanceof Tenant) { throw new NotFoundHttpException; } if (! $user instanceof User) { abort(403); } try { $claimedFinding = app(FindingWorkflowService::class)->claim($record, $tenant, $user); Notification::make() ->success() ->title('Finding claimed') ->body('The finding left shared intake and is now assigned to you.') ->actions([ Action::make('open_my_findings') ->label('Open my findings') ->url(MyFindingsInbox::getUrl(panel: 'admin')), Action::make('open_finding') ->label('Open finding') ->url($this->findingDetailUrl($claimedFinding)), ]) ->send(); } catch (ConflictHttpException) { Notification::make() ->warning() ->title('Finding already claimed') ->body('Another operator claimed this finding first. The intake queue has been refreshed.') ->send(); } $this->resetTable(); if (method_exists($this, 'unmountAction')) { $this->unmountAction(); } }), fn () => null, ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } private function authorizePageAccess(): void { $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User) { abort(403); } if (! $workspace instanceof Workspace) { throw new NotFoundHttpException; } $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $workspace)) { throw new NotFoundHttpException; } if ($this->visibleTenants() === []) { abort(403); } } /** * @return array */ private 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 = $user->tenants() ->where('tenants.workspace_id', (int) $workspace->getKey()) ->where('tenants.status', 'active') ->orderBy('tenants.name') ->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id']) ->all(); } private function workspace(): ?Workspace { if ($this->workspace instanceof Workspace) { return $this->workspace; } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if (! is_int($workspaceId)) { return null; } return $this->workspace = Workspace::query()->whereKey($workspaceId)->first(); } private function queueBaseQuery(): Builder { $workspace = $this->workspace(); $tenantIds = array_map( static fn (Tenant $tenant): int => (int) $tenant->getKey(), $this->visibleTenants(), ); if (! $workspace instanceof Workspace) { return Finding::query()->whereRaw('1 = 0'); } return Finding::query() ->with(['tenant', 'ownerUser', 'assigneeUser']) ->withSubjectDisplayName() ->where('workspace_id', (int) $workspace->getKey()) ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) ->whereNull('assignee_user_id') ->whereIn('status', Finding::openStatuses()); } private function queueViewQuery(): Builder { return $this->filteredQueueQuery(includeTenantFilter: false, queueView: $this->currentQueueView(), applyOrdering: true); } private function filteredQueueQuery( bool $includeTenantFilter = true, ?string $queueView = null, bool $applyOrdering = true, ): Builder { $query = $this->queueBaseQuery(); $resolvedQueueView = $queueView ?? $this->queueView; if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) { $query->where('tenant_id', $tenantId); } if ($resolvedQueueView === 'needs_triage') { $query->whereIn('status', [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, ]); } if (! $applyOrdering) { return $query; } return $query ->orderByRaw( "case when due_at is not null and due_at < ? then 0 when status = ? then 1 when status = ? then 2 else 3 end asc", [now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW], ) ->orderByRaw('case when due_at is null then 1 else 0 end asc') ->orderBy('due_at') ->orderByDesc('id'); } /** * @return array */ private function tenantFilterOptions(): array { return collect($this->visibleTenants()) ->mapWithKeys(static fn (Tenant $tenant): array => [ (string) $tenant->getKey() => (string) $tenant->name, ]) ->all(); } private function applyRequestedTenantPrefilter(): void { $requestedTenant = request()->query('tenant'); if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { return; } foreach ($this->visibleTenants() 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 normalizeTenantFilterState(): void { $configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value'); if ($configuredTenantFilter === null || $configuredTenantFilter === '') { return; } if ($this->currentTenantFilterId() !== null) { return; } $this->removeTableFilter('tenant_id'); } /** * @return array */ private function currentQueueFiltersState(): array { $persisted = session()->get($this->getTableFiltersSessionKey(), []); return array_replace_recursive( is_array($persisted) ? $persisted : [], $this->tableFilters ?? [], ); } private function currentTenantFilterId(): ?int { $tenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value'); if (! is_numeric($tenantFilter)) { return null; } $tenantId = (int) $tenantFilter; foreach ($this->visibleTenants() as $tenant) { if ((int) $tenant->getKey() === $tenantId) { return $tenantId; } } return null; } private function filteredTenant(): ?Tenant { $tenantId = $this->currentTenantFilterId(); if (! is_int($tenantId)) { return null; } foreach ($this->visibleTenants() as $tenant) { if ((int) $tenant->getKey() === $tenantId) { return $tenant; } } return null; } private function activeVisibleTenant(): ?Tenant { $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); if (! $activeTenant instanceof Tenant) { return null; } foreach ($this->visibleTenants() as $tenant) { if ($tenant->is($activeTenant)) { return $tenant; } } return null; } private function tenantPrefilterSource(): string { $tenant = $this->filteredTenant(); if (! $tenant instanceof Tenant) { return 'none'; } $activeTenant = $this->activeVisibleTenant(); if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) { return 'active_tenant_context'; } return 'explicit_filter'; } private function ownerContext(Finding $record): ?string { if ($record->owner_user_id === null) { return null; } return 'Owner: '.FindingResource::accountableOwnerDisplayFor($record); } private function reopenedCue(Finding $record): ?string { if ($record->reopened_at === null) { return null; } return 'Reopened'; } private function queueReason(Finding $record): string { return in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, ], true) ? 'Needs triage' : 'Unassigned'; } private function queueReasonColor(Finding $record): string { return $this->queueReason($record) === 'Needs triage' ? 'warning' : 'gray'; } private function tenantFilterAloneExcludesRows(): bool { if ($this->currentTenantFilterId() === null) { return false; } if ((clone $this->filteredQueueQuery())->exists()) { return false; } return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists(); } private function findingDetailUrl(Finding $record): string { $tenant = $record->tenant; if (! $tenant instanceof Tenant) { return '#'; } $url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant); return $this->appendQuery($url, $this->navigationContext()->toQuery()); } private function navigationContext(): CanonicalNavigationContext { return new CanonicalNavigationContext( sourceSurface: 'findings.intake', canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), tenantId: $this->currentTenantFilterId(), backLinkLabel: 'Back to findings intake', backLinkUrl: $this->queueUrl(), ); } private function queueUrl(array $overrides = []): string { $resolvedTenant = array_key_exists('tenant', $overrides) ? $overrides['tenant'] : $this->filteredTenant()?->external_id; $resolvedView = array_key_exists('view', $overrides) ? $overrides['view'] : $this->currentQueueView(); return static::getUrl( panel: 'admin', parameters: array_filter([ 'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null, 'view' => $resolvedView === 'needs_triage' ? 'needs_triage' : null, ], static fn (mixed $value): bool => $value !== null && $value !== ''), ); } private function resolveRequestedQueueView(): string { $requestedView = request()->query('view'); return $requestedView === 'needs_triage' ? 'needs_triage' : 'unassigned'; } private function currentQueueView(): string { return $this->queueView === 'needs_triage' ? 'needs_triage' : 'unassigned'; } private function queueViewLabel(string $queueView): string { return $queueView === 'needs_triage' ? 'Needs triage' : 'Unassigned'; } /** * @return array */ private function emptyStateActions(): array { $emptyState = $this->emptyState(); $action = Action::make((string) $emptyState['action_name']) ->label((string) $emptyState['action_label']) ->icon('heroicon-o-arrow-right') ->color('gray'); if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') { return [ $action->action(fn (): mixed => $this->clearTenantFilter()), ]; } return [ $action->url((string) $emptyState['action_url']), ]; } /** * @param array $query */ private function appendQuery(string $url, array $query): string { if ($query === []) { return $url; } return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); } }