Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary - add the new admin findings intake queue at `/admin/findings/intake` with fixed `Unassigned` and `Needs triage` views, tenant-safe filtering, claim flow, and continuity into tenant finding detail and `My Findings` - add Spec 222 artifacts (`spec`, `plan`, `tasks`, `research`, `data model`, `quickstart`, contract, checklist) and register the new admin page - fix follow-up regressions uncovered during full-suite validation around findings action-surface declarations, findings list default columns, provider-blocked run messaging, operation catalog aliases, and workspace overview query volume ## Validation - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php tests/Feature/Findings/FindingsClaimHandoffTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact` ## Notes - Filament remains on v5 with Livewire v4-compatible patterns - provider registration remains unchanged in `apps/platform/bootstrap/providers.php` - no new assets or schema changes are introduced Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #260
776 lines
26 KiB
PHP
776 lines
26 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Findings;
|
|
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\Finding;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Services\Findings\FindingWorkflowService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\OperateHub\OperateHubShell;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use UnitEnum;
|
|
|
|
class FindingsIntakeQueue extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $title = 'Findings intake';
|
|
|
|
protected static ?string $slug = 'findings/intake';
|
|
|
|
protected string $view = 'filament.pages.findings.findings-intake-queue';
|
|
|
|
/**
|
|
* @var array<int, Tenant>|null
|
|
*/
|
|
private ?array $authorizedTenants = null;
|
|
|
|
/**
|
|
* @var array<int, Tenant>|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<string, mixed>
|
|
*/
|
|
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<int, array<string, mixed>>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<int, Tenant>
|
|
*/
|
|
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<int, Tenant>
|
|
*/
|
|
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<string, string>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<int, Action>
|
|
*/
|
|
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<string, mixed> $query
|
|
*/
|
|
private function appendQuery(string $url, array $query): string
|
|
{
|
|
if ($query === []) {
|
|
return $url;
|
|
}
|
|
|
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
|
}
|
|
}
|