## 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
206 lines
8.6 KiB
PHP
206 lines
8.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
|
|
|
use App\Filament\Resources\TenantReviewResource;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\User;
|
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
|
use App\Services\TenantReviews\TenantReviewService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\TenantReviewStatus;
|
|
use Filament\Actions;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ViewRecord;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class ViewTenantReview extends ViewRecord
|
|
{
|
|
protected static string $resource = TenantReviewResource::class;
|
|
|
|
protected function resolveRecord(int|string $key): Model
|
|
{
|
|
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
|
}
|
|
|
|
protected function authorizeAccess(): void
|
|
{
|
|
$tenant = TenantReviewResource::panelTenantContext();
|
|
$record = $this->getRecord();
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
|
abort(404);
|
|
}
|
|
|
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->can('view', $record)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->icon('heroicon-o-eye')
|
|
->color('gray')
|
|
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
|
->url(fn (): ?string => $this->record->operation_run_id
|
|
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
|
: null),
|
|
Actions\Action::make('view_export')
|
|
->label('View executive pack')
|
|
->icon('heroicon-o-document-arrow-down')
|
|
->color('gray')
|
|
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
|
->url(fn (): ?string => $this->record->currentExportReviewPack
|
|
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
|
: null),
|
|
Actions\Action::make('view_evidence')
|
|
->label('View evidence snapshot')
|
|
->icon('heroicon-o-shield-check')
|
|
->color('gray')
|
|
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
|
->url(fn (): ?string => $this->record->evidenceSnapshot
|
|
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
|
: null),
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('refresh_review')
|
|
->label('Refresh review')
|
|
->icon('heroicon-o-arrow-path')
|
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
->requiresConfirmation()
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
try {
|
|
app(TenantReviewService::class)->refresh($this->record, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()->success()->title('Refresh review queued')->send();
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('publish_review')
|
|
->label('Publish review')
|
|
->icon('heroicon-o-check-badge')
|
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
->requiresConfirmation()
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
try {
|
|
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
|
Notification::make()->success()->title('Review published')->send();
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('export_executive_pack')
|
|
->label('Export executive pack')
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->hidden(fn (): bool => ! in_array($this->record->status, [
|
|
TenantReviewStatus::Ready->value,
|
|
TenantReviewStatus::Published->value,
|
|
], true))
|
|
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
Actions\ActionGroup::make([
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('create_next_review')
|
|
->label('Create next review')
|
|
->icon('heroicon-o-document-duplicate')
|
|
->hidden(fn (): bool => ! $this->record->isPublished())
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
try {
|
|
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('archive_review')
|
|
->label('Archive review')
|
|
->icon('heroicon-o-archive-box')
|
|
->color('danger')
|
|
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
|
->requiresConfirmation()
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
|
$this->refreshFormData(['status', 'archived_at']);
|
|
|
|
Notification::make()->success()->title('Review archived')->send();
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
])
|
|
->label('More')
|
|
->icon('heroicon-m-ellipsis-vertical')
|
|
->color('gray'),
|
|
];
|
|
}
|
|
}
|