## Summary - add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export - extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles - add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates ## Testing - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact --filter="TenantReview"` - `CI=1 vendor/bin/sail artisan test --compact` ## Notes - Livewire v4+ compliant via existing Filament v5 stack - panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php` - `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply - destructive review actions use action handlers with confirmation and policy enforcement Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #185
308 lines
12 KiB
PHP
308 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Reviews;
|
|
|
|
use App\Filament\Resources\TenantReviewResource;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
use App\Support\Filament\FilterPresets;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
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\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
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\NotFoundHttpException;
|
|
use UnitEnum;
|
|
|
|
class ReviewRegister extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
|
|
protected static ?string $navigationLabel = 'Reviews';
|
|
|
|
protected static ?string $title = 'Review Register';
|
|
|
|
protected static ?string $slug = 'reviews';
|
|
|
|
protected string $view = 'filament.pages.reviews.review-register';
|
|
|
|
/**
|
|
* @var array<int, Tenant>|null
|
|
*/
|
|
private ?array $authorizedTenants = null;
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->authorizePageAccess();
|
|
|
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
$this->getTableFiltersSessionKey(),
|
|
['status', 'published_state', 'completeness_state'],
|
|
request(),
|
|
);
|
|
|
|
$this->applyRequestedTenantPrefilter();
|
|
$this->mountInteractsWithTable();
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Action::make('clear_filters')
|
|
->label('Clear filters')
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->hasActiveFilters())
|
|
->action(function (): void {
|
|
$this->resetTable();
|
|
}),
|
|
];
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->query(fn (): Builder => $this->registerQuery())
|
|
->defaultSort('generated_at', 'desc')
|
|
->paginated(TablePaginationProfiles::customPage())
|
|
->persistFiltersInSession()
|
|
->persistSearchInSession()
|
|
->persistSortInSession()
|
|
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
|
->columns([
|
|
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
|
TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
|
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)),
|
|
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
|
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
|
TextColumn::make('summary.publish_blockers')
|
|
->label('Publish blockers')
|
|
->formatStateUsing(static function (mixed $state): string {
|
|
if (! is_array($state) || $state === []) {
|
|
return '0';
|
|
}
|
|
|
|
return (string) count($state);
|
|
}),
|
|
])
|
|
->filters([
|
|
SelectFilter::make('tenant_id')
|
|
->label('Tenant')
|
|
->options(fn (): array => $this->tenantFilterOptions())
|
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
|
->searchable(),
|
|
SelectFilter::make('status')
|
|
->options([
|
|
'draft' => 'Draft',
|
|
'ready' => 'Ready',
|
|
'published' => 'Published',
|
|
'archived' => 'Archived',
|
|
'superseded' => 'Superseded',
|
|
'failed' => 'Failed',
|
|
]),
|
|
SelectFilter::make('completeness_state')
|
|
->label('Completeness')
|
|
->options([
|
|
'complete' => 'Complete',
|
|
'partial' => 'Partial',
|
|
'missing' => 'Missing',
|
|
'stale' => 'Stale',
|
|
]),
|
|
SelectFilter::make('published_state')
|
|
->label('Published state')
|
|
->options([
|
|
'published' => 'Published',
|
|
'unpublished' => 'Not published',
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
return match ($data['value'] ?? null) {
|
|
'published' => $query->whereNotNull('published_at'),
|
|
'unpublished' => $query->whereNull('published_at'),
|
|
default => $query,
|
|
};
|
|
}),
|
|
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
|
])
|
|
->actions([
|
|
Action::make('view_review')
|
|
->label('View review')
|
|
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
|
Action::make('export_executive_pack')
|
|
->label('Export executive pack')
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
|
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
|
&& in_array($record->status, ['ready', 'published'], true))
|
|
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
|
])
|
|
->bulkActions([])
|
|
->emptyStateHeading('No review records match this view')
|
|
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
|
->emptyStateActions([
|
|
Action::make('clear_filters_empty')
|
|
->label('Clear filters')
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->action(fn (): mixed => $this->resetTable()),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Tenant>
|
|
*/
|
|
public 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 = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
|
}
|
|
|
|
private function authorizePageAccess(): void
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
$service = app(TenantReviewRegisterService::class);
|
|
|
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
if ($this->authorizedTenants() === []) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
}
|
|
|
|
private function registerQuery(): Builder
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return TenantReview::query()->whereRaw('1 = 0');
|
|
}
|
|
|
|
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function tenantFilterOptions(): array
|
|
{
|
|
return collect($this->authorizedTenants())
|
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
(string) $tenant->getKey() => $tenant->name,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function defaultTenantFilter(): ?string
|
|
{
|
|
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
|
|
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
|
? (string) $tenantId
|
|
: null;
|
|
}
|
|
|
|
private function applyRequestedTenantPrefilter(): void
|
|
{
|
|
$requestedTenant = 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->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
private function hasActiveFilters(): bool
|
|
{
|
|
$filters = array_filter((array) $this->tableFilters);
|
|
|
|
return $filters !== [];
|
|
}
|
|
|
|
private function workspace(): ?Workspace
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
return is_numeric($workspaceId)
|
|
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
|
: null;
|
|
}
|
|
}
|