TenantAtlas/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
ahmido 23ef20f86d feat(decision-register): implement Decision Register (spec 265) (#321)
This PR contains the committed changes for specs/265-decision-register-approval.

Commit: b5671cbf

Automated PR created by Copilot at user's request.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #321
2026-05-02 19:02:04 +00:00

719 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Resources\FindingExceptionResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
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\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class DecisionRegister extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Decision register';
protected static ?int $navigationSort = 6;
protected static ?string $title = 'Decision register';
protected static ?string $slug = 'governance/decisions';
protected string $view = 'filament.pages.governance.decision-register';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleDecisionTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $registerPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredRegisterPayload = null;
/**
* @var array<int, array<string, mixed>>|null
*/
private ?array $rowPayloadByExceptionId = null;
private ?Workspace $workspace = null;
public ?int $tenantId = null;
public string $registerState = 'open';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep tenant and register-state scope visible without introducing a second mutation surface.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The decision register keeps one dominant row action and avoids a More menu in v1.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The decision register is read-only and intentionally omits bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Filtered empty states stay truthful and provide one path back to the broader register scope.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
}
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = static::resolveWorkspaceFromRequest();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
return false;
}
if (static::hasRequestedTenantPrefilter()) {
return true;
}
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
if ($visibleTenants === []) {
return false;
}
if (request()->query('register_state') === 'recently_closed') {
return true;
}
return (int) (app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $visibleTenants,
registerState: 'open',
)['counts']['open'] ?? 0) > 0;
}
public function mount(): void
{
$this->mountInteractsWithTable();
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->registerState = $this->resolveRequestedRegisterState();
$this->ensureRegisterIsVisible();
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedRegisterState = array_key_exists('register_state', $overrides)
? $overrides['register_state']
: $this->registerState;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public function appliedScope(): array
{
return [
'workspace_label' => $this->workspace()?->name,
'tenant_label' => $this->selectedTenant()?->name,
'register_state_label' => $this->registerStateLabel($this->registerState),
'visible_count' => $this->registerPayload()['counts'][$this->registerState] ?? 0,
];
}
/**
* @return list<array{key: string, label: string, count: int}>
*/
public function availableRegisterStates(): array
{
$counts = $this->registerPayload()['counts'] ?? ['open' => 0, 'recently_closed' => 0];
return [
[
'key' => 'open',
'label' => 'Open decisions',
'count' => (int) ($counts['open'] ?? 0),
],
[
'key' => 'recently_closed',
'label' => 'Recently closed',
'count' => (int) ($counts['recently_closed'] ?? 0),
],
];
}
public function hasTenantPrefilter(): bool
{
return $this->selectedTenant() instanceof Tenant;
}
public function isActiveRegisterState(string $registerState): bool
{
return $this->registerState === $registerState;
}
public function emptyStateHeading(): string
{
if ($this->tenantFilterAloneExcludesRows()) {
return 'This tenant filter is hiding other visible decision follow-through';
}
if ($this->registerState === 'recently_closed') {
return 'No recently closed decisions match this filter right now.';
}
return 'No open decisions match this filter right now.';
}
public function emptyStateDescription(): string
{
if ($this->tenantFilterAloneExcludesRows()) {
return 'The current tenant scope is calm, but other visible tenants in this workspace still have open governance decisions.';
}
if ($this->registerState === 'recently_closed') {
return 'Switch back to open decisions to continue the current follow-through lane, or widen the tenant scope if you were filtering the register.';
}
return 'Try widening the tenant scope or switch to recently closed decisions if you are checking what was just finished.';
}
public function emptyStateActionLabel(): ?string
{
if ($this->tenantFilterAloneExcludesRows()) {
return 'Clear tenant filter';
}
if ($this->registerState === 'recently_closed') {
return 'Open current decisions';
}
return null;
}
public function emptyStateActionUrl(): ?string
{
if ($this->tenantFilterAloneExcludesRows()) {
return $this->pageUrl(['tenant' => null]);
}
if ($this->registerState === 'recently_closed') {
return $this->pageUrl(['register_state' => 'open']);
}
return null;
}
public function table(Table $table): Table
{
return $table
->query($this->tableQuery())
->defaultSort('review_due_at', 'asc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(null)
->columns([
TextColumn::make('tenant.name')
->label('Tenant')
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
->sortable(),
TextColumn::make('current_validity_state')
->label('Impact')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextColumn::make('owner.name')
->label('Owner')
->placeholder('—')
->toggleable(),
TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->since()
->placeholder('—')
->tooltip(fn (FindingException $record): ?string => $record->review_due_at?->toDayDateTimeString())
->sortable(),
TextColumn::make('proof_availability')
->label('Proof')
->state(function (FindingException $record): string {
$referenceCount = (int) data_get($record->evidence_summary ?? [], 'reference_count', 0);
return $referenceCount > 0
? $referenceCount.' evidence linked'
: 'No linked proof';
})
->wrap(),
TextColumn::make('next_action_label')
->label('Next action')
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['next_action_label'] ?? null)
->visible(fn (): bool => $this->registerState === 'open')
->wrap(),
TextColumn::make('closure_reason')
->label('Closure reason')
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['closure_reason'] ?? null)
->placeholder('—')
->visible(fn (): bool => $this->registerState === 'recently_closed')
->wrap(),
])
->actions([
Action::make('open_decision')
->label('Open decision')
->color('gray')
->url(fn (FindingException $record): ?string => $this->decisionUrl($record)),
])
->emptyStateHeading($this->emptyStateHeading())
->emptyStateDescription($this->emptyStateDescription())
->emptyStateActions($this->emptyStateActions());
}
/**
* @return list<Tables\Actions\Action>
*/
private function emptyStateActions(): array
{
$label = $this->emptyStateActionLabel();
$url = $this->emptyStateActionUrl();
if (! is_string($label) || ! is_string($url)) {
return [];
}
return [
Action::make('empty_state_scope_action')
->label($label)
->url($url),
];
}
/**
* @return Builder<FindingException>
*/
private function tableQuery(): Builder
{
$tenantIds = array_values(array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->currentScopeTenants(),
));
$query = FindingException::query()
->where('workspace_id', (int) $this->workspace()?->getKey())
->whereIn('tenant_id', $tenantIds)
->with(['tenant', 'owner', 'currentDecision']);
if ($this->registerState === 'recently_closed') {
return $query
->whereIn('status', [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
])
->whereHas('currentDecision', function (Builder $decisionQuery): void {
$decisionQuery->where('decided_at', '>=', now()->startOfDay()->subDays(30));
});
}
return $query
->whereNotIn('status', [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
]);
}
private function authorizeWorkspaceMembership(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function ensureRegisterIsVisible(): void
{
if ($this->visibleDecisionTenants() === []) {
abort(403);
}
if ($this->tenantId !== null || $this->registerState !== 'open') {
return;
}
if ((int) ($this->registerPayload()['counts']['open'] ?? 0) === 0) {
abort(403);
}
}
/**
* @return array<int, Tenant>
*/
private 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 = static::resolveAuthorizedTenantsFor($user, $workspace);
}
/**
* @return array<int, Tenant>
*/
private function visibleDecisionTenants(): array
{
if ($this->visibleDecisionTenants !== null) {
return $this->visibleDecisionTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || ! $workspace instanceof Workspace || $tenants === []) {
return $this->visibleDecisionTenants = [];
}
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant_id', 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->tenantId = (int) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function resolveRequestedRegisterState(): string
{
$registerState = request()->query('register_state');
if (! is_string($registerState)) {
return 'open';
}
return in_array($registerState, ['open', 'recently_closed'], true)
? $registerState
: 'open';
}
private static function hasRequestedTenantPrefilter(): bool
{
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
return is_string($requestedTenant) || is_numeric($requestedTenant);
}
private static function resolveWorkspaceFromRequest(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return array<int, Tenant>
*/
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
{
return $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
/**
* @param array<int, Tenant>|null $authorizedTenants
* @return array<int, Tenant>
*/
private static function resolveVisibleDecisionTenantsFor(User $user, Workspace $workspace, ?array $authorizedTenants = null): array
{
$tenants = $authorizedTenants ?? static::resolveAuthorizedTenantsFor($user, $workspace);
if ($tenants === []) {
return [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW),
));
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
return $this->workspace = static::resolveWorkspaceFromRequest();
}
private function selectedTenant(): ?Tenant
{
if (! is_int($this->tenantId)) {
return null;
}
foreach ($this->visibleDecisionTenants() as $tenant) {
if ((int) $tenant->getKey() === $this->tenantId) {
return $tenant;
}
}
return null;
}
/**
* @return array<int, Tenant>
*/
private function currentScopeTenants(): array
{
$selectedTenant = $this->selectedTenant();
if ($selectedTenant instanceof Tenant) {
return [$selectedTenant];
}
return $this->visibleDecisionTenants();
}
/**
* @return array<string, mixed>
*/
private function registerPayload(): array
{
if (is_array($this->registerPayload)) {
return $this->registerPayload;
}
$workspace = $this->workspace();
if (! $workspace instanceof Workspace) {
return $this->registerPayload = [
'rows' => [],
'counts' => ['open' => 0, 'recently_closed' => 0],
];
}
return $this->registerPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $this->currentScopeTenants(),
registerState: $this->registerState,
);
}
/**
* @return array<string, mixed>
*/
private function unfilteredRegisterPayload(): array
{
if (is_array($this->unfilteredRegisterPayload)) {
return $this->unfilteredRegisterPayload;
}
$workspace = $this->workspace();
if (! $workspace instanceof Workspace) {
return $this->unfilteredRegisterPayload = [
'rows' => [],
'counts' => ['open' => 0, 'recently_closed' => 0],
];
}
return $this->unfilteredRegisterPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $this->visibleDecisionTenants(),
registerState: 'open',
);
}
/**
* @return array<string, mixed>
*/
private function rowPayload(FindingException $record): array
{
if (! is_array($this->rowPayloadByExceptionId)) {
$this->rowPayloadByExceptionId = collect($this->registerPayload()['rows'] ?? [])
->keyBy('exception_id')
->all();
}
return $this->rowPayloadByExceptionId[(int) $record->getKey()] ?? [];
}
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->registerState !== 'open') {
return false;
}
if (($this->registerPayload()['rows'] ?? []) !== []) {
return false;
}
return (int) ($this->unfilteredRegisterPayload()['counts']['open'] ?? 0) > 0;
}
private function registerStateLabel(string $registerState): string
{
return match ($registerState) {
'recently_closed' => 'Recently closed',
default => 'Open decisions',
};
}
public function decisionUrl(FindingException $record): ?string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return null;
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
$this->navigationContext()->toQuery(),
);
}
private function navigationContext(): CanonicalNavigationContext
{
return CanonicalNavigationContext::forDecisionRegister(
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkUrl: $this->pageUrl(),
);
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
$queryString = http_build_query($query);
if ($queryString === '') {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.$queryString;
}
}