|null */ public ?array $arrivalState = null; private ?PortfolioArrivalContext $cachedArrivalContext = null; private ?int $cachedArrivalContextTenantId = null; private bool $hasCachedArrivalContext = false; /** * @var array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array}|null */ private ?array $cachedConcernTruth = null; private ?int $cachedConcernTruthTenantId = null; /** * @var array|null>> */ private array $cachedReviewStates = []; protected static bool $isLazy = false; protected int|string|array $columnSpan = 'full'; protected string $view = 'filament.widgets.tenant.triage-arrival-continuity'; public function mount(): void { $this->arrivalState = PortfolioArrivalContextToken::decode( request()->query(PortfolioArrivalContextToken::QUERY_PARAMETER), ); } /** * @return array */ protected function getViewData(): array { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return ['context' => null, 'reviewState' => null]; } $context = $this->resolveArrivalContext($tenant); if ($context === null) { return ['context' => null, 'reviewState' => null]; } return [ 'context' => $context, 'reviewState' => $this->currentReviewStateFor($tenant, $context->concernFamily), ]; } public function markReviewedAction(): Action { return UiEnforcement::forAction( Action::make('markReviewed') ->label('Mark reviewed') ->icon('heroicon-o-check-circle') ->color('success') ->requiresConfirmation() ->modalHeading('Mark reviewed') ->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_REVIEWED)) ->visible(fn (): bool => $this->canShowReviewActions()) ->action(function (TenantTriageReviewService $service): void { $this->handleReviewMutation(TenantTriageReview::STATE_REVIEWED, $service); }), ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE) ->apply(); } public function markFollowUpNeededAction(): Action { return UiEnforcement::forAction( Action::make('markFollowUpNeeded') ->label('Mark follow-up needed') ->icon('heroicon-o-exclamation-triangle') ->color('warning') ->requiresConfirmation() ->modalHeading('Mark follow-up needed') ->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_FOLLOW_UP_NEEDED)) ->visible(fn (): bool => $this->canShowReviewActions()) ->action(function (TenantTriageReviewService $service): void { $this->handleReviewMutation(TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $service); }), ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE) ->apply(); } private function canShowReviewActions(): bool { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return false; } $context = $this->resolveArrivalContext($tenant); if ($context === null) { return false; } return ($this->currentReviewStateFor($tenant, $context->concernFamily)['current_concern_present'] ?? false) === true; } private function reviewModalDescription(string $targetManualState): \Closure { return function () use ($targetManualState): string { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return 'This triage session is no longer available.'; } $context = $this->resolveArrivalContext($tenant); if ($context === null) { return 'This triage session is no longer available.'; } $reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily); if (($reviewState['current_concern_present'] ?? false) !== true) { return 'This triage session no longer points at a current concern.'; } $currentLabel = BadgeRenderer::spec( BadgeDomain::TenantTriageReviewState, (string) ($reviewState['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED), )->label; $targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label; return implode("\n\n", [ 'Concern family: '.$this->concernFamilyLabel($context->concernFamily), 'Current review state: '.$currentLabel, 'Target state: '.$targetLabel, 'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.', ]); }; } private function handleReviewMutation(string $targetManualState, TenantTriageReviewService $service): void { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return; } $context = $this->resolveArrivalContext($tenant); if ($context === null) { Notification::make() ->title('No triage session available') ->warning() ->send(); return; } $reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily); if (($reviewState['current_concern_present'] ?? false) !== true) { Notification::make() ->title('No current concern to update') ->body('This arrival context no longer maps to an active concern.') ->warning() ->send(); return; } $concernTruth = $this->concernTruthFor($tenant); $actor = auth()->user(); $review = match ($targetManualState) { TenantTriageReview::STATE_REVIEWED => $service->markReviewed( tenant: $tenant, concernFamily: $context->concernFamily, backupHealth: $concernTruth['backupHealth'], recoveryEvidence: $concernTruth['recoveryEvidence'], actor: $actor instanceof User ? $actor : null, ), TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded( tenant: $tenant, concernFamily: $context->concernFamily, backupHealth: $concernTruth['backupHealth'], recoveryEvidence: $concernTruth['recoveryEvidence'], actor: $actor instanceof User ? $actor : null, ), default => null, }; if (! $review instanceof TenantTriageReview) { return; } $this->clearConcernCachesFor($tenant); Notification::make() ->title('Review state updated') ->body(sprintf( '%s is now %s for %s.', $tenant->name, BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label, $this->concernFamilyLabel($context->concernFamily), )) ->success() ->send(); } /** * @return array|null */ private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array { $tenantId = (int) $tenant->getKey(); if (array_key_exists($tenantId, $this->cachedReviewStates) && array_key_exists($concernFamily, $this->cachedReviewStates[$tenantId])) { return $this->cachedReviewStates[$tenantId][$concernFamily]; } $concernTruth = $this->concernTruthFor($tenant); $reviewState = app(TenantTriageReviewStateResolver::class)->resolveMany( workspaceId: (int) $tenant->workspace_id, tenantIds: [$tenantId], backupHealthByTenant: [$tenantId => $concernTruth['backupHealth']], recoveryEvidenceByTenant: [$tenantId => $concernTruth['recoveryEvidence']], )['rows'][$tenantId][$concernFamily] ?? null; $this->cachedReviewStates[$tenantId][$concernFamily] = $reviewState; return $reviewState; } /** * @return array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array} */ private function concernTruthFor(Tenant $tenant): array { $tenantId = (int) $tenant->getKey(); if ($this->cachedConcernTruthTenantId === $tenantId && is_array($this->cachedConcernTruth)) { return $this->cachedConcernTruth; } $this->cachedConcernTruthTenantId = $tenantId; $this->cachedConcernTruth = [ 'backupHealth' => app(TenantBackupHealthResolver::class)->assess($tenant), 'recoveryEvidence' => app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant), ]; return $this->cachedConcernTruth; } private function clearConcernCachesFor(Tenant $tenant): void { $tenantId = (int) $tenant->getKey(); if ($this->cachedConcernTruthTenantId === $tenantId) { $this->cachedConcernTruthTenantId = null; $this->cachedConcernTruth = null; } if (array_key_exists($tenantId, $this->cachedReviewStates)) { unset($this->cachedReviewStates[$tenantId]); } } private function concernFamilyLabel(string $concernFamily): string { return match ($concernFamily) { PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health', PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence', default => 'Portfolio concern', }; } private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext { $tenantId = (int) $tenant->getKey(); if ($this->arrivalState === null) { $this->cachedArrivalContextTenantId = $tenantId; $this->cachedArrivalContext = null; $this->hasCachedArrivalContext = true; return null; } if ($this->hasCachedArrivalContext && $this->cachedArrivalContextTenantId === $tenantId) { return $this->cachedArrivalContext; } $concernTruth = $this->concernTruthFor($tenant); $this->cachedArrivalContextTenantId = $tenantId; $this->cachedArrivalContext = app(PortfolioArrivalContextResolver::class)->resolveStateWithTruth( $tenant, $this->arrivalState, $concernTruth['backupHealth'], $concernTruth['recoveryEvidence'], ); $this->hasCachedArrivalContext = true; return $this->cachedArrivalContext; } }