getId() === 'tenant'; } 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::TENANT_REVIEW_VIEW, $tenant); } public static function canView(Model $record): bool { $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) { return false; } if (! $user->canAccessTenant($tenant)) { return false; } if ((int) $record->tenant_id !== (int) $tenant->getKey()) { return false; } return $user->can('view', $record); } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.'); } public static function getEloquentQuery(): Builder { return static::getTenantOwnedEloquentQuery() ->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections']) ->latest('generated_at') ->latest('id'); } public static function resolveScopedRecordOrFail(int|string|null $record): Model { return static::resolveTenantOwnedRecordOrFail($record); } public static function form(Schema $schema): Schema { return $schema; } public static function infolist(Schema $schema): Schema { return $schema->schema([ Section::make('Review') ->schema([ TextEntry::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), TextEntry::make('completeness_state') ->label('Completeness') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), TextEntry::make('tenant.name')->label('Tenant'), TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('published_at')->dateTime()->placeholder('—'), TextEntry::make('evidenceSnapshot.id') ->label('Evidence snapshot') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) : null), TextEntry::make('currentExportReviewPack.id') ->label('Current export') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack ? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant) : null), TextEntry::make('fingerprint') ->copyable() ->placeholder('—') ->columnSpanFull() ->fontFamily('mono') ->size(TextSize::ExtraSmall), ]) ->columns(2) ->columnSpanFull(), Section::make('Executive posture') ->schema([ ViewEntry::make('review_summary') ->hiddenLabel() ->view('filament.infolists.entries.tenant-review-summary') ->state(fn (TenantReview $record): array => static::summaryPresentation($record)) ->columnSpanFull(), ]) ->columnSpanFull(), Section::make('Sections') ->schema([ RepeatableEntry::make('sections') ->hiddenLabel() ->schema([ TextEntry::make('title'), TextEntry::make('completeness_state') ->label('Completeness') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), TextEntry::make('measured_at')->dateTime()->placeholder('—'), Section::make('Details') ->schema([ ViewEntry::make('section_payload') ->hiddenLabel() ->view('filament.infolists.entries.tenant-review-section') ->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record)) ->columnSpanFull(), ]) ->collapsible() ->collapsed() ->columnSpanFull(), ]) ->columns(3), ]) ->columnSpanFull(), ]); } public static function table(Table $table): Table { return $table ->defaultSort('generated_at', 'desc') ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)) ->columns([ Tables\Columns\TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->sortable(), Tables\Columns\TextColumn::make('completeness_state') ->label('Completeness') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)) ->sortable(), Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'), Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'), Tables\Columns\IconColumn::make('summary.has_ready_export') ->label('Export') ->boolean(), Tables\Columns\TextColumn::make('fingerprint') ->toggleable(isToggledHiddenByDefault: true) ->searchable(), ]) ->filters([ Tables\Filters\SelectFilter::make('status') ->options(collect(TenantReviewStatus::cases()) ->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)]) ->all()), Tables\Filters\SelectFilter::make('completeness_state') ->options([ 'complete' => 'Complete', 'partial' => 'Partial', 'missing' => 'Missing', 'stale' => 'Stale', ]), \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), ]) ->actions([ Actions\Action::make('view_review') ->label('View review') ->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)), UiEnforcement::forTableAction( Actions\Action::make('export_executive_pack') ->label('Export executive pack') ->icon('heroicon-o-arrow-down-tray') ->visible(fn (TenantReview $record): bool => in_array($record->status, [ TenantReviewStatus::Ready->value, TenantReviewStatus::Published->value, ], true)) ->action(fn (TenantReview $record): mixed => static::executeExport($record)), fn (TenantReview $record): TenantReview => $record, ) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) ->preserveVisibility() ->apply(), ]) ->bulkActions([]) ->emptyStateHeading('No tenant reviews yet') ->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.') ->emptyStateActions([ static::makeCreateReviewAction( name: 'create_first_review', label: 'Create first review', icon: 'heroicon-o-plus', ), ]); } public static function getPages(): array { return [ 'index' => Pages\ListTenantReviews::route('/'), 'view' => Pages\ViewTenantReview::route('/{record}'), ]; } public static function makeCreateReviewAction( string $name = 'create_review', string $label = 'Create review', string $icon = 'heroicon-o-plus', ): Actions\Action { return UiEnforcement::forAction( Actions\Action::make($name) ->label($label) ->icon($icon) ->form([ Section::make('Evidence basis') ->schema([ Select::make('evidence_snapshot_id') ->label('Evidence snapshot') ->required() ->options(fn (): array => static::evidenceSnapshotOptions()) ->searchable() ->helperText('Choose the anchored evidence snapshot for this review.'), ]), ]) ->action(fn (array $data): mixed => static::executeCreateReview($data)), ) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) ->apply(); } /** * @param array $data */ public static function executeCreateReview(array $data): void { $tenant = Filament::getTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { Notification::make()->danger()->title('Unable to create review — missing context.')->send(); return; } if (! $user->canAccessTenant($tenant)) { abort(404); } if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) { abort(403); } $snapshotId = $data['evidence_snapshot_id'] ?? null; $snapshot = is_numeric($snapshotId) ? EvidenceSnapshot::query() ->whereKey((int) $snapshotId) ->where('tenant_id', (int) $tenant->getKey()) ->first() : null; if (! $snapshot instanceof EvidenceSnapshot) { Notification::make()->danger()->title('Select a valid evidence snapshot.')->send(); return; } try { $review = app(TenantReviewService::class)->create($tenant, $snapshot, $user); } catch (\Throwable $throwable) { Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send(); return; } if (! $review->wasRecentlyCreated) { Notification::make() ->success() ->title('Review already available') ->body('A matching mutable review already exists for this evidence basis.') ->actions([ Actions\Action::make('view_review') ->label('View review') ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ]) ->send(); return; } $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) ->body('The review is being composed in the background.'); if ($review->operation_run_id) { $toast->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ]); } $toast->send(); } public static function executeExport(TenantReview $review): void { $review->loadMissing(['tenant', 'currentExportReviewPack']); $user = auth()->user(); if (! $user instanceof User || ! $review->tenant instanceof Tenant) { Notification::make()->danger()->title('Unable to export review — missing context.')->send(); return; } if (! $user->canAccessTenant($review->tenant)) { abort(404); } if (! $user->can('export', $review)) { abort(403); } $service = app(ReviewPackService::class); if ($service->checkActiveRunForReview($review)) { OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value) ->body('An executive pack export is already queued or running for this review.') ->send(); return; } try { $pack = $service->generateFromReview($review, $user, [ 'include_pii' => true, 'include_operations' => true, ]); } catch (\Throwable $throwable) { Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send(); return; } if (! $pack->wasRecentlyCreated) { Notification::make() ->success() ->title('Executive pack already available') ->body('A matching executive pack already exists for this review.') ->actions([ Actions\Action::make('view_pack') ->label('View pack') ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ]) ->send(); return; } OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) ->body('The executive pack is being generated in the background.') ->send(); } /** * @param array $parameters */ public static function tenantScopedUrl( string $page = 'index', array $parameters = [], ?Tenant $tenant = null, ?string $panel = null, ): string { $panelId = $panel ?? 'tenant'; return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant); } /** * @return array */ private static function evidenceSnapshotOptions(): array { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return []; } return EvidenceSnapshot::query() ->where('tenant_id', (int) $tenant->getKey()) ->whereNotNull('generated_at') ->orderByDesc('generated_at') ->orderByDesc('id') ->get() ->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [ (string) $snapshot->getKey() => sprintf( '#%d · %s · %s', (int) $snapshot->getKey(), Str::headline((string) $snapshot->completeness_state), $snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending' ), ]) ->all(); } /** * @return array */ private static function summaryPresentation(TenantReview $record): array { $summary = is_array($record->summary) ? $record->summary : []; return [ 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'metrics' => [ ['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], ], ]; } /** * @return array */ private static function sectionPresentation(TenantReviewSection $section): array { $summary = is_array($section->summary_payload) ? $section->summary_payload : []; $render = is_array($section->render_payload) ? $section->render_payload : []; $review = $section->tenantReview; $tenant = $section->tenant; return [ 'summary' => collect($summary)->map(function (mixed $value, string $key): ?array { if (is_array($value) || $value === null || $value === '') { return null; } return [ 'label' => Str::headline($key), 'value' => (string) $value, ]; })->filter()->values()->all(), 'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [], 'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [], 'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null, 'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [], 'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null, 'links' => [], ]; } }