getId() === 'admin') { return false; } return parent::shouldRegisterNavigation(); } public static function canViewAny(): bool { $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } if (! $user->canAccessTenant($tenant)) { return false; } return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant); } public static function canView(Model $record): bool { $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) { return false; } return ! $record instanceof FindingException || ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id); } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.'); } public static function getEloquentQuery(): Builder { return static::getTenantOwnedEloquentQuery() ->with(static::relationshipsForView()); } public static function resolveScopedRecordOrFail(int|string|null $record): Model { return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView())); } public static function form(Schema $schema): Schema { return $schema; } public static function infolist(Schema $schema): Schema { return $schema->schema([ Section::make('Exception') ->schema([ TextEntry::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus)) ->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)), TextEntry::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)), TextEntry::make('governance_warning') ->label('Governance warning') ->state(fn (FindingException $record): ?string => static::governanceWarning($record)) ->color(fn (FindingException $record): string => static::governanceWarningColor($record)) ->columnSpanFull() ->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null), TextEntry::make('tenant.name')->label('Tenant'), TextEntry::make('finding_summary') ->label('Finding') ->state(fn (FindingException $record): string => static::findingSummary($record)), TextEntry::make('requester.name')->label('Requested by')->placeholder('—'), TextEntry::make('owner.name')->label('Owner')->placeholder('—'), TextEntry::make('approver.name')->label('Approved by')->placeholder('—'), TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'), TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'), TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'), TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'), TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'), TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(), TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(), TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(), ]) ->columns(2), Section::make('Decision history') ->schema([ RepeatableEntry::make('decisions') ->hiddenLabel() ->schema([ TextEntry::make('decision_type')->label('Decision'), TextEntry::make('actor.name')->label('Actor')->placeholder('—'), TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'), TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(), ]) ->columns(3), ]), Section::make('Evidence references') ->schema([ RepeatableEntry::make('evidenceReferences') ->hiddenLabel() ->schema([ TextEntry::make('label')->label('Label'), TextEntry::make('source_type')->label('Source'), TextEntry::make('source_id')->label('Source ID')->placeholder('—'), TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'), TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'), TextEntry::make('summary_payload') ->label('Summary') ->state(function (FindingExceptionEvidenceReference $record): ?string { if ($record->summary_payload === [] || $record->summary_payload === null) { return null; } return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null; }) ->placeholder('—') ->columnSpanFull(), ]) ->columns(2), ]) ->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()), ]); } public static function table(Table $table): Table { return $table ->defaultSort('requested_at', 'desc') ->paginated(TablePaginationProfiles::resource()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\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)) ->sortable(), Tables\Columns\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)) ->sortable(), Tables\Columns\TextColumn::make('finding_summary') ->label('Finding') ->state(fn (FindingException $record): string => static::findingSummary($record)) ->searchable(), Tables\Columns\TextColumn::make('governance_warning') ->label('Governance warning') ->state(fn (FindingException $record): ?string => static::governanceWarning($record)) ->color(fn (FindingException $record): string => static::governanceWarningColor($record)) ->wrap(), Tables\Columns\TextColumn::make('requester.name') ->label('Requested by') ->placeholder('—'), Tables\Columns\TextColumn::make('owner.name') ->label('Owner') ->placeholder('—'), Tables\Columns\TextColumn::make('review_due_at') ->label('Review due') ->dateTime() ->placeholder('—') ->sortable(), Tables\Columns\TextColumn::make('requested_at') ->label('Requested') ->dateTime() ->sortable(), ]) ->filters([ SelectFilter::make('status') ->options(FilterOptionCatalog::findingExceptionStatuses()), SelectFilter::make('current_validity_state') ->label('Validity') ->options(FilterOptionCatalog::findingExceptionValidityStates()), ]) ->actions([ Action::make('renew_exception') ->label('Renew exception') ->icon('heroicon-o-arrow-path') ->color('warning') ->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed()) ->requiresConfirmation() ->form([ Select::make('owner_user_id') ->label('Owner') ->required() ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), Textarea::make('request_reason') ->label('Renewal reason') ->rows(4) ->required() ->maxLength(2000), DateTimePicker::make('review_due_at') ->label('Review due at') ->required() ->seconds(false), DateTimePicker::make('expires_at') ->label('Requested expiry') ->seconds(false), Repeater::make('evidence_references') ->label('Evidence references') ->schema([ TextInput::make('label') ->label('Label') ->required() ->maxLength(255), TextInput::make('source_type') ->label('Source type') ->required() ->maxLength(255), TextInput::make('source_id') ->label('Source ID') ->maxLength(255), TextInput::make('source_fingerprint') ->label('Fingerprint') ->maxLength(255), DateTimePicker::make('measured_at') ->label('Measured at') ->seconds(false), ]) ->defaultItems(0) ->collapsed(), ]) ->action(function (FindingException $record, array $data, FindingExceptionService $service): void { $user = auth()->user(); if (! $user instanceof User || ! $record->tenant instanceof Tenant) { abort(404); } try { $service->renew($record, $user, $data); } catch (InvalidArgumentException $exception) { Notification::make() ->title('Renewal request failed') ->body($exception->getMessage()) ->danger() ->send(); return; } Notification::make() ->title('Renewal request submitted') ->success() ->send(); }), Action::make('revoke_exception') ->label('Revoke exception') ->icon('heroicon-o-no-symbol') ->color('danger') ->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked()) ->requiresConfirmation() ->form([ Textarea::make('revocation_reason') ->label('Revocation reason') ->rows(4) ->required() ->maxLength(2000), ]) ->action(function (FindingException $record, array $data, FindingExceptionService $service): void { $user = auth()->user(); if (! $user instanceof User) { abort(404); } try { $service->revoke($record, $user, $data); } catch (InvalidArgumentException $exception) { Notification::make() ->title('Exception revocation failed') ->body($exception->getMessage()) ->danger() ->send(); return; } Notification::make() ->title('Exception revoked') ->success() ->send(); }), ]) ->bulkActions([]) ->emptyStateHeading('No exceptions match this view') ->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.') ->emptyStateIcon('heroicon-o-shield-exclamation') ->emptyStateActions([ Action::make('open_findings') ->label('Open findings') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->url(fn (): string => FindingResource::getUrl('index')), ]); } public static function getPages(): array { return [ 'index' => Pages\ListFindingExceptions::route('/'), 'view' => Pages\ViewFindingException::route('/{record}'), ]; } /** * @return array> */ private static function relationshipsForView(): array { return [ 'tenant', 'requester', 'owner', 'approver', 'currentDecision', 'decisions.actor', 'evidenceReferences', 'finding' => fn ($query) => $query->withSubjectDisplayName(), ]; } /** * @return array */ private static function tenantMemberOptions(): array { $tenant = static::resolveTenantContextForCurrentPanel(); if (! $tenant instanceof Tenant) { return []; } return \App\Models\TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->join('users', 'users.id', '=', 'tenant_memberships.user_id') ->orderBy('users.name') ->pluck('users.name', 'users.id') ->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name]) ->all(); } private static function findingSummary(FindingException $record): string { $summary = $record->finding?->resolvedSubjectDisplayName(); if (is_string($summary) && trim($summary) !== '') { return trim($summary); } return 'Finding #'.$record->finding_id; } private static function canManageRecord(FindingException $record): bool { $user = auth()->user(); return $user instanceof User && $record->tenant instanceof Tenant && $user->canAccessTenant($record->tenant) && $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant); } private static function governanceWarning(FindingException $record): ?string { $finding = $record->relationLoaded('finding') ? $record->finding : $record->finding()->withSubjectDisplayName()->first(); if (! $finding instanceof \App\Models\Finding) { return null; } return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record); } private static function governanceWarningColor(FindingException $record): string { $finding = $record->relationLoaded('finding') ? $record->finding : $record->finding()->withSubjectDisplayName()->first(); if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) { return 'warning'; } return 'danger'; } }