TenantAtlas/resources/views/filament/partials/context-bar.blade.php
ahmido 0c709df54e Spec 129: add workspace admin home overview (#157)
## Summary
- make `/admin` the canonical workspace-level home instead of implicitly forcing tenant context
- add a new Filament workspace overview page with bounded workspace-safe widgets, quick actions, and empty states
- align panel routing, middleware, redirect helpers, and tests with the new workspace-home semantics
- add Spec 129 design artifacts, contracts, and focused Pest coverage for landing, navigation, content, operations, and authorization

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php tests/Feature/Filament/WorkspaceOverviewLandingTest.php tests/Feature/Filament/WorkspaceOverviewNavigationTest.php tests/Feature/Filament/WorkspaceOverviewContentTest.php tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliance is preserved through Filament v5 usage.
- Panel provider registration remains in `bootstrap/providers.php` for Laravel 12.
- This feature adds a workspace overview page for the admin panel home; it does not introduce destructive actions.
- No new Filament assets were added, so there is no additional `filament:assets` deployment requirement for this branch.
- Manual browser QA for the quickstart scenarios was not completed in this session because the local browser opened at the Microsoft login flow without an authenticated test session.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #157
2026-03-09 21:53:25 +00:00

228 lines
12 KiB
PHP

@php
use App\Filament\Pages\ChooseWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspace(request());
$user = auth()->user();
$canSeeAllWorkspaceTenants = false;
if ($user instanceof User && $workspace) {
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
$canSeeAllWorkspaceTenants = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('user_id', (int) $user->getKey())
->whereIn('role', $roles)
->exists();
}
$tenants = collect();
if ($user instanceof User && $workspace) {
if ($canSeeAllWorkspaceTenants) {
$tenants = Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->orderBy('name')
->get();
} else {
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->filter(fn ($tenant): bool => $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey())
->values();
}
}
$operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
$route = request()->route();
$routeName = (string) ($route?->getName() ?? '');
$tenantQuery = request()->query('tenant');
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
if ($currentTenantName === null && $hasTenantQuery) {
$queriedTenant = $tenants->first(function ($tenant) use ($tenantQuery): bool {
return $tenant instanceof Tenant
&& ((string) $tenant->external_id === (string) $tenantQuery || (string) $tenant->getKey() === (string) $tenantQuery);
});
if ($queriedTenant instanceof Tenant) {
$currentTenantName = $queriedTenant->getFilamentName();
}
}
$isTenantScopedRoute = $route?->hasParameter('tenant')
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
@endphp
@php
$tenantLabel = $currentTenantName ?? 'All tenants';
$workspaceLabel = $workspace?->name ?? 'Select workspace';
$hasActiveTenant = $currentTenantName !== null;
$workspaceUrl = $workspace
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Select tenant';
@endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
{{-- Workspace label: standalone link --}}
<a
href="{{ $workspaceUrl }}"
wire:navigate
class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium text-gray-700 transition hover:bg-gray-50 hover:text-primary-600 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-primary-400"
>
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
{{ $workspaceLabel }}
</a>
@if ($workspace)
<x-filament::icon icon="heroicon-m-chevron-right" class="h-3 w-3 shrink-0 text-gray-300 dark:text-gray-600" />
@endif
{{-- Dropdown trigger: tenant label + chevron --}}
<x-filament::dropdown placement="bottom-start" teleport width="xs">
<x-slot name="trigger">
<button
type="button"
aria-label="{{ $workspace ? 'Tenant scope' : 'Select tenant' }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
>
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $tenantTriggerLabel }}
</span>
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
</button>
</x-slot>
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
{{-- Workspace section --}}
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Workspace
</div>
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-4 w-4 text-gray-500 dark:text-gray-400" />
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $workspaceLabel }}</span>
</div>
<a
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
Switch workspace
</a>
</div>
<a
href="{{ route('admin.home') }}"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
>
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
Workspace Home
</a>
</div>
@if ($workspace)
<div class="border-t border-gray-200 dark:border-white/10"></div>
{{-- Tenant section --}}
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Tenant scope
</div>
@if ($canSeeAllWorkspaceTenants)
<span class="text-xs text-gray-400 dark:text-gray-500">all visible</span>
@endif
</div>
@if ($isTenantScopedRoute)
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
<span class="ml-auto text-xs text-primary-500 dark:text-primary-400">locked</span>
</div>
@else
@if ($tenants->isEmpty())
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
{{ $canSeeAllWorkspaceTenants ? 'No tenants in this workspace.' : 'No accessible tenants.' }}
</div>
@else
<input
type="text"
class="fi-input fi-text-input w-full"
placeholder="Search tenants…"
x-model="query"
/>
<div class="max-h-48 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
@foreach ($tenants as $tenant)
@php
$isActive = $currentTenantName !== null && $tenant->getFilamentName() === $currentTenantName;
@endphp
<form method="POST" action="{{ route('admin.select-tenant') }}">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->getKey() }}" />
<button
type="submit"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{ $isActive ? 'bg-primary-50 font-medium text-primary-700 dark:bg-primary-950/30 dark:text-primary-300' : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5' }}"
data-search="{{ (string) str($tenant->getFilamentName())->lower() }}"
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
>
@if ($isActive)
<x-filament::icon icon="heroicon-s-check-circle" class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400" />
@else
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
@endif
{{ $tenant->getFilamentName() }}
</button>
</form>
@endforeach
</div>
@if ($canClearTenantContext)
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope
</button>
</form>
@endif
@endif
@endif
</div>
@else
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
Choose a workspace first.
</div>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
</div>