719 lines
23 KiB
PHP
719 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Governance;
|
|
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Models\FindingException;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
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\Pages\Page;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use UnitEnum;
|
|
|
|
class DecisionRegister extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $navigationLabel = 'Decision register';
|
|
|
|
protected static ?int $navigationSort = 6;
|
|
|
|
protected static ?string $title = 'Decision register';
|
|
|
|
protected static ?string $slug = 'governance/decisions';
|
|
|
|
protected string $view = 'filament.pages.governance.decision-register';
|
|
|
|
/**
|
|
* @var array<int, Tenant>|null
|
|
*/
|
|
private ?array $authorizedTenants = null;
|
|
|
|
/**
|
|
* @var array<int, Tenant>|null
|
|
*/
|
|
private ?array $visibleDecisionTenants = null;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
private ?array $registerPayload = null;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
private ?array $unfilteredRegisterPayload = null;
|
|
|
|
/**
|
|
* @var array<int, array<string, mixed>>|null
|
|
*/
|
|
private ?array $rowPayloadByExceptionId = null;
|
|
|
|
private ?Workspace $workspace = null;
|
|
|
|
public ?int $tenantId = null;
|
|
|
|
public string $registerState = 'open';
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep tenant and register-state scope visible without introducing a second mutation surface.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The decision register keeps one dominant row action and avoids a More menu in v1.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The decision register is read-only and intentionally omits bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Filtered empty states stay truthful and provide one path back to the broader register scope.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
|
|
}
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
|
return false;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspace = static::resolveWorkspaceFromRequest();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
if (! $resolver->isMember($user, $workspace)) {
|
|
return false;
|
|
}
|
|
|
|
if (static::hasRequestedTenantPrefilter()) {
|
|
return true;
|
|
}
|
|
|
|
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
|
|
|
|
if ($visibleTenants === []) {
|
|
return false;
|
|
}
|
|
|
|
if (request()->query('register_state') === 'recently_closed') {
|
|
return true;
|
|
}
|
|
|
|
return (int) (app(GovernanceDecisionRegisterBuilder::class)->build(
|
|
workspace: $workspace,
|
|
visibleTenants: $visibleTenants,
|
|
registerState: 'open',
|
|
)['counts']['open'] ?? 0) > 0;
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->mountInteractsWithTable();
|
|
$this->authorizeWorkspaceMembership();
|
|
$this->applyRequestedTenantPrefilter();
|
|
$this->registerState = $this->resolveRequestedRegisterState();
|
|
$this->ensureRegisterIsVisible();
|
|
}
|
|
|
|
public function pageUrl(array $overrides = []): string
|
|
{
|
|
$selectedTenant = $this->selectedTenant();
|
|
$resolvedTenant = array_key_exists('tenant', $overrides)
|
|
? $overrides['tenant']
|
|
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
|
|
$resolvedRegisterState = array_key_exists('register_state', $overrides)
|
|
? $overrides['register_state']
|
|
: $this->registerState;
|
|
|
|
return static::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
|
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
);
|
|
}
|
|
|
|
public function appliedScope(): array
|
|
{
|
|
return [
|
|
'workspace_label' => $this->workspace()?->name,
|
|
'tenant_label' => $this->selectedTenant()?->name,
|
|
'register_state_label' => $this->registerStateLabel($this->registerState),
|
|
'visible_count' => $this->registerPayload()['counts'][$this->registerState] ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array{key: string, label: string, count: int}>
|
|
*/
|
|
public function availableRegisterStates(): array
|
|
{
|
|
$counts = $this->registerPayload()['counts'] ?? ['open' => 0, 'recently_closed' => 0];
|
|
|
|
return [
|
|
[
|
|
'key' => 'open',
|
|
'label' => 'Open decisions',
|
|
'count' => (int) ($counts['open'] ?? 0),
|
|
],
|
|
[
|
|
'key' => 'recently_closed',
|
|
'label' => 'Recently closed',
|
|
'count' => (int) ($counts['recently_closed'] ?? 0),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function hasTenantPrefilter(): bool
|
|
{
|
|
return $this->selectedTenant() instanceof Tenant;
|
|
}
|
|
|
|
public function isActiveRegisterState(string $registerState): bool
|
|
{
|
|
return $this->registerState === $registerState;
|
|
}
|
|
|
|
public function emptyStateHeading(): string
|
|
{
|
|
if ($this->tenantFilterAloneExcludesRows()) {
|
|
return 'This tenant filter is hiding other visible decision follow-through';
|
|
}
|
|
|
|
if ($this->registerState === 'recently_closed') {
|
|
return 'No recently closed decisions match this filter right now.';
|
|
}
|
|
|
|
return 'No open decisions match this filter right now.';
|
|
}
|
|
|
|
public function emptyStateDescription(): string
|
|
{
|
|
if ($this->tenantFilterAloneExcludesRows()) {
|
|
return 'The current tenant scope is calm, but other visible tenants in this workspace still have open governance decisions.';
|
|
}
|
|
|
|
if ($this->registerState === 'recently_closed') {
|
|
return 'Switch back to open decisions to continue the current follow-through lane, or widen the tenant scope if you were filtering the register.';
|
|
}
|
|
|
|
return 'Try widening the tenant scope or switch to recently closed decisions if you are checking what was just finished.';
|
|
}
|
|
|
|
public function emptyStateActionLabel(): ?string
|
|
{
|
|
if ($this->tenantFilterAloneExcludesRows()) {
|
|
return 'Clear tenant filter';
|
|
}
|
|
|
|
if ($this->registerState === 'recently_closed') {
|
|
return 'Open current decisions';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function emptyStateActionUrl(): ?string
|
|
{
|
|
if ($this->tenantFilterAloneExcludesRows()) {
|
|
return $this->pageUrl(['tenant' => null]);
|
|
}
|
|
|
|
if ($this->registerState === 'recently_closed') {
|
|
return $this->pageUrl(['register_state' => 'open']);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->query($this->tableQuery())
|
|
->defaultSort('review_due_at', 'asc')
|
|
->paginated(TablePaginationProfiles::resource())
|
|
->persistFiltersInSession()
|
|
->persistSearchInSession()
|
|
->persistSortInSession()
|
|
->recordUrl(null)
|
|
->columns([
|
|
TextColumn::make('tenant.name')
|
|
->label('Tenant')
|
|
->searchable()
|
|
->sortable(),
|
|
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(),
|
|
TextColumn::make('current_validity_state')
|
|
->label('Impact')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
|
TextColumn::make('owner.name')
|
|
->label('Owner')
|
|
->placeholder('—')
|
|
->toggleable(),
|
|
TextColumn::make('review_due_at')
|
|
->label('Review due')
|
|
->dateTime()
|
|
->since()
|
|
->placeholder('—')
|
|
->tooltip(fn (FindingException $record): ?string => $record->review_due_at?->toDayDateTimeString())
|
|
->sortable(),
|
|
TextColumn::make('proof_availability')
|
|
->label('Proof')
|
|
->state(function (FindingException $record): string {
|
|
$referenceCount = (int) data_get($record->evidence_summary ?? [], 'reference_count', 0);
|
|
|
|
return $referenceCount > 0
|
|
? $referenceCount.' evidence linked'
|
|
: 'No linked proof';
|
|
})
|
|
->wrap(),
|
|
TextColumn::make('next_action_label')
|
|
->label('Next action')
|
|
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['next_action_label'] ?? null)
|
|
->visible(fn (): bool => $this->registerState === 'open')
|
|
->wrap(),
|
|
TextColumn::make('closure_reason')
|
|
->label('Closure reason')
|
|
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['closure_reason'] ?? null)
|
|
->placeholder('—')
|
|
->visible(fn (): bool => $this->registerState === 'recently_closed')
|
|
->wrap(),
|
|
])
|
|
->actions([
|
|
Action::make('open_decision')
|
|
->label('Open decision')
|
|
->color('gray')
|
|
->url(fn (FindingException $record): ?string => $this->decisionUrl($record)),
|
|
])
|
|
->emptyStateHeading($this->emptyStateHeading())
|
|
->emptyStateDescription($this->emptyStateDescription())
|
|
->emptyStateActions($this->emptyStateActions());
|
|
}
|
|
|
|
/**
|
|
* @return list<Tables\Actions\Action>
|
|
*/
|
|
private function emptyStateActions(): array
|
|
{
|
|
$label = $this->emptyStateActionLabel();
|
|
$url = $this->emptyStateActionUrl();
|
|
|
|
if (! is_string($label) || ! is_string($url)) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
Action::make('empty_state_scope_action')
|
|
->label($label)
|
|
->url($url),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Builder<FindingException>
|
|
*/
|
|
private function tableQuery(): Builder
|
|
{
|
|
$tenantIds = array_values(array_map(
|
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
|
$this->currentScopeTenants(),
|
|
));
|
|
|
|
$query = FindingException::query()
|
|
->where('workspace_id', (int) $this->workspace()?->getKey())
|
|
->whereIn('tenant_id', $tenantIds)
|
|
->with(['tenant', 'owner', 'currentDecision']);
|
|
|
|
if ($this->registerState === 'recently_closed') {
|
|
return $query
|
|
->whereIn('status', [
|
|
FindingException::STATUS_REJECTED,
|
|
FindingException::STATUS_REVOKED,
|
|
FindingException::STATUS_SUPERSEDED,
|
|
])
|
|
->whereHas('currentDecision', function (Builder $decisionQuery): void {
|
|
$decisionQuery->where('decided_at', '>=', now()->startOfDay()->subDays(30));
|
|
});
|
|
}
|
|
|
|
return $query
|
|
->whereNotIn('status', [
|
|
FindingException::STATUS_REJECTED,
|
|
FindingException::STATUS_REVOKED,
|
|
FindingException::STATUS_SUPERSEDED,
|
|
]);
|
|
}
|
|
|
|
private function authorizeWorkspaceMembership(): 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;
|
|
}
|
|
}
|
|
|
|
private function ensureRegisterIsVisible(): void
|
|
{
|
|
if ($this->visibleDecisionTenants() === []) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($this->tenantId !== null || $this->registerState !== 'open') {
|
|
return;
|
|
}
|
|
|
|
if ((int) ($this->registerPayload()['counts']['open'] ?? 0) === 0) {
|
|
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 = static::resolveAuthorizedTenantsFor($user, $workspace);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Tenant>
|
|
*/
|
|
private function visibleDecisionTenants(): array
|
|
{
|
|
if ($this->visibleDecisionTenants !== null) {
|
|
return $this->visibleDecisionTenants;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
$tenants = $this->authorizedTenants();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace || $tenants === []) {
|
|
return $this->visibleDecisionTenants = [];
|
|
}
|
|
|
|
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
|
|
}
|
|
|
|
private function applyRequestedTenantPrefilter(): void
|
|
{
|
|
$requestedTenant = request()->query('tenant_id', 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->tenantId = (int) $tenant->getKey();
|
|
|
|
return;
|
|
}
|
|
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
private function resolveRequestedRegisterState(): string
|
|
{
|
|
$registerState = request()->query('register_state');
|
|
|
|
if (! is_string($registerState)) {
|
|
return 'open';
|
|
}
|
|
|
|
return in_array($registerState, ['open', 'recently_closed'], true)
|
|
? $registerState
|
|
: 'open';
|
|
}
|
|
|
|
private static function hasRequestedTenantPrefilter(): bool
|
|
{
|
|
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
|
|
|
return is_string($requestedTenant) || is_numeric($requestedTenant);
|
|
}
|
|
|
|
private static function resolveWorkspaceFromRequest(): ?Workspace
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if (! is_int($workspaceId)) {
|
|
return null;
|
|
}
|
|
|
|
return Workspace::query()->whereKey($workspaceId)->first();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Tenant>
|
|
*/
|
|
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
|
|
{
|
|
return $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();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant>|null $authorizedTenants
|
|
* @return array<int, Tenant>
|
|
*/
|
|
private static function resolveVisibleDecisionTenantsFor(User $user, Workspace $workspace, ?array $authorizedTenants = null): array
|
|
{
|
|
$tenants = $authorizedTenants ?? static::resolveAuthorizedTenantsFor($user, $workspace);
|
|
|
|
if ($tenants === []) {
|
|
return [];
|
|
}
|
|
|
|
$resolver = app(CapabilityResolver::class);
|
|
$resolver->primeMemberships(
|
|
$user,
|
|
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
|
);
|
|
|
|
return array_values(array_filter(
|
|
$tenants,
|
|
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW),
|
|
));
|
|
}
|
|
|
|
private function workspace(): ?Workspace
|
|
{
|
|
if ($this->workspace instanceof Workspace) {
|
|
return $this->workspace;
|
|
}
|
|
|
|
return $this->workspace = static::resolveWorkspaceFromRequest();
|
|
}
|
|
|
|
private function selectedTenant(): ?Tenant
|
|
{
|
|
if (! is_int($this->tenantId)) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($this->visibleDecisionTenants() as $tenant) {
|
|
if ((int) $tenant->getKey() === $this->tenantId) {
|
|
return $tenant;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Tenant>
|
|
*/
|
|
private function currentScopeTenants(): array
|
|
{
|
|
$selectedTenant = $this->selectedTenant();
|
|
|
|
if ($selectedTenant instanceof Tenant) {
|
|
return [$selectedTenant];
|
|
}
|
|
|
|
return $this->visibleDecisionTenants();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function registerPayload(): array
|
|
{
|
|
if (is_array($this->registerPayload)) {
|
|
return $this->registerPayload;
|
|
}
|
|
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return $this->registerPayload = [
|
|
'rows' => [],
|
|
'counts' => ['open' => 0, 'recently_closed' => 0],
|
|
];
|
|
}
|
|
|
|
return $this->registerPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
|
workspace: $workspace,
|
|
visibleTenants: $this->currentScopeTenants(),
|
|
registerState: $this->registerState,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function unfilteredRegisterPayload(): array
|
|
{
|
|
if (is_array($this->unfilteredRegisterPayload)) {
|
|
return $this->unfilteredRegisterPayload;
|
|
}
|
|
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return $this->unfilteredRegisterPayload = [
|
|
'rows' => [],
|
|
'counts' => ['open' => 0, 'recently_closed' => 0],
|
|
];
|
|
}
|
|
|
|
return $this->unfilteredRegisterPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
|
workspace: $workspace,
|
|
visibleTenants: $this->visibleDecisionTenants(),
|
|
registerState: 'open',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function rowPayload(FindingException $record): array
|
|
{
|
|
if (! is_array($this->rowPayloadByExceptionId)) {
|
|
$this->rowPayloadByExceptionId = collect($this->registerPayload()['rows'] ?? [])
|
|
->keyBy('exception_id')
|
|
->all();
|
|
}
|
|
|
|
return $this->rowPayloadByExceptionId[(int) $record->getKey()] ?? [];
|
|
}
|
|
|
|
private function tenantFilterAloneExcludesRows(): bool
|
|
{
|
|
if (! is_int($this->tenantId) || $this->registerState !== 'open') {
|
|
return false;
|
|
}
|
|
|
|
if (($this->registerPayload()['rows'] ?? []) !== []) {
|
|
return false;
|
|
}
|
|
|
|
return (int) ($this->unfilteredRegisterPayload()['counts']['open'] ?? 0) > 0;
|
|
}
|
|
|
|
private function registerStateLabel(string $registerState): string
|
|
{
|
|
return match ($registerState) {
|
|
'recently_closed' => 'Recently closed',
|
|
default => 'Open decisions',
|
|
};
|
|
}
|
|
|
|
public function decisionUrl(FindingException $record): ?string
|
|
{
|
|
$tenant = $record->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return $this->appendQuery(
|
|
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
|
|
$this->navigationContext()->toQuery(),
|
|
);
|
|
}
|
|
|
|
private function navigationContext(): CanonicalNavigationContext
|
|
{
|
|
return CanonicalNavigationContext::forDecisionRegister(
|
|
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
|
tenantId: $this->tenantId,
|
|
backLinkUrl: $this->pageUrl(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
private function appendQuery(string $url, array $query): string
|
|
{
|
|
$queryString = http_build_query($query);
|
|
|
|
if ($queryString === '') {
|
|
return $url;
|
|
}
|
|
|
|
$separator = str_contains($url, '?') ? '&' : '?';
|
|
|
|
return $url.$separator.$queryString;
|
|
}
|
|
} |