schema([ Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'), Infolists\Components\TextEntry::make('version_number')->label('Version'), Infolists\Components\TextEntry::make('policy_type'), Infolists\Components\TextEntry::make('platform'), Infolists\Components\TextEntry::make('created_by')->label('Actor'), Infolists\Components\TextEntry::make('captured_at')->dateTime(), Tabs::make() ->activeTab(1) ->persistTabInQueryString('tab') ->columnSpanFull() ->tabs([ Tab::make('Normalized settings') ->schema([ Infolists\Components\ViewEntry::make('normalized_settings') ->view('filament.infolists.entries.normalized-settings') ->state(function (PolicyVersion $record) { $normalized = app(PolicyNormalizer::class)->normalize( is_array($record->snapshot) ? $record->snapshot : [], $record->policy_type ?? '', $record->platform ); $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); return $normalized; }), ]), Tab::make('Raw JSON') ->schema([ Infolists\Components\ViewEntry::make('snapshot_pretty') ->view('filament.infolists.entries.snapshot-json') ->state(fn (PolicyVersion $record) => $record->snapshot ?? []), ]), Tab::make('Diff') ->schema([ Infolists\Components\ViewEntry::make('normalized_diff') ->view('filament.infolists.entries.normalized-diff') ->state(function (PolicyVersion $record) { $normalizer = app(PolicyNormalizer::class); $diff = app(VersionDiff::class); $previous = $record->previous(); $from = $previous ? $normalizer->flattenForDiff($previous->snapshot ?? [], $previous->policy_type ?? '', $previous->platform) : []; $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform); return $diff->compare($from, $to); }), Infolists\Components\TextEntry::make('diff') ->label('Diff JSON vs previous') ->state(function (PolicyVersion $record) { $previous = $record->previous(); if (! $previous) { return ['summary' => 'No previous version']; } return app(VersionDiff::class) ->compare($previous->snapshot ?? [], $record->snapshot ?? []); }) ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) ->copyable(), ]), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(), Tables\Columns\TextColumn::make('version_number')->sortable(), Tables\Columns\TextColumn::make('policy_type')->badge(), Tables\Columns\TextColumn::make('platform')->badge(), Tables\Columns\TextColumn::make('created_by')->label('Actor'), Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), ]) ->filters([ TrashedFilter::make() ->label('Archived') ->placeholder('Active') ->trueLabel('All') ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make() ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), Actions\ActionGroup::make([ Actions\Action::make('archive') ->label('Archive') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => ! $record->trashed()) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { $record->delete(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'policy_version.deleted', resourceType: 'policy_version', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] ); } Notification::make() ->title('Policy version archived') ->success() ->send(); }), Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => $record->trashed()) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'policy_version.force_deleted', resourceType: 'policy_version', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] ); } $record->forceDelete(); Notification::make() ->title('Policy version permanently deleted') ->success() ->send(); }), Actions\Action::make('restore') ->label('Restore') ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => $record->trashed()) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { $record->restore(); if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, action: 'policy_version.restored', resourceType: 'policy_version', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] ); } Notification::make() ->title('Policy version restored') ->success() ->send(); }), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ BulkAction::make('bulk_prune_versions') ->label('Prune Versions') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return $isOnlyTrashed; }) ->form(function (Collection $records) { $fields = [ Forms\Components\TextInput::make('retention_days') ->label('Retention Days') ->helperText('Versions captured within the last N days will be skipped.') ->numeric() ->required() ->default(90) ->minValue(1), ]; if ($records->count() >= 20) { $fields[] = Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', ]); } return $fields; }) ->action(function (Collection $records, array $data) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $retentionDays = (int) ($data['retention_days'] ?? 90); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk prune started') ->body("Pruning {$count} policy versions in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->duration(8000) ->sendToDatabase($user) ->send(); BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays); } else { BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays); } }) ->deselectRecordsAfterCompletion(), BulkAction::make('bulk_restore_versions') ->label('Restore Versions') ->icon('heroicon-o-arrow-uturn-left') ->color('success') ->requiresConfirmation() ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return ! $isOnlyTrashed; }) ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') ->action(function (Collection $records) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk restore started') ->body("Restoring {$count} policy versions in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->duration(8000) ->sendToDatabase($user) ->send(); BulkPolicyVersionRestoreJob::dispatch($run->id); } else { BulkPolicyVersionRestoreJob::dispatchSync($run->id); } }) ->deselectRecordsAfterCompletion(), BulkAction::make('bulk_force_delete_versions') ->label('Force Delete Versions') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; $isOnlyTrashed = in_array($value, [0, '0', false], true); return ! $isOnlyTrashed; }) ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') ->form([ Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', ]), ]) ->action(function (Collection $records, array $data) { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); $ids = $records->pluck('id')->toArray(); $service = app(BulkOperationService::class); $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count); if ($count >= 20) { Notification::make() ->title('Bulk force delete started') ->body("Force deleting {$count} policy versions in the background. Check the progress bar in the bottom right corner.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() ->duration(8000) ->sendToDatabase($user) ->send(); BulkPolicyVersionForceDeleteJob::dispatch($run->id); } else { BulkPolicyVersionForceDeleteJob::dispatchSync($run->id); } }) ->deselectRecordsAfterCompletion(), ]), ]); } public static function getEloquentQuery(): Builder { $tenantId = Tenant::current()->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->with('policy'); } public static function getPages(): array { return [ 'index' => Pages\ListPolicyVersions::route('/'), 'view' => Pages\ViewPolicyVersion::route('/{record}'), ]; } }