feat(tenant): add team and member manager admin components

- Add TeamManager Livewire component for managing workspace teams
- Add MemberManager Livewire component for managing workspace members
- Add admin routes for team and member management
- Add blade templates for team and member management UI
- Support team permissions, bulk operations, and custom member permissions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-27 10:21:25 +00:00
parent 86dbf4e763
commit 68525ca247
6 changed files with 1369 additions and 0 deletions

View file

@ -132,8 +132,14 @@ class Boot extends ServiceProvider
{
$event->views($this->moduleName, __DIR__.'/View/Blade');
if (file_exists(__DIR__.'/Routes/admin.php')) {
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
}
// Admin Livewire components
$event->livewire('tenant.admin.entitlement-webhook-manager', View\Modal\Admin\EntitlementWebhookManager::class);
$event->livewire('tenant.admin.team-manager', View\Modal\Admin\TeamManager::class);
$event->livewire('tenant.admin.member-manager', View\Modal\Admin\MemberManager::class);
}
public function onApiRoutes(ApiRoutesRegistering $event): void

24
Routes/admin.php Normal file
View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Tenant Admin Routes
|--------------------------------------------------------------------------
|
| Routes for workspace team and member management in the admin panel.
|
*/
Route::middleware(['web', 'auth', 'admin.domain'])->prefix('admin/tenant')->name('hub.admin.tenant.')->group(function () {
// Team Manager
Route::get('/teams', \Core\Mod\Tenant\View\Modal\Admin\TeamManager::class)
->name('teams');
// Member Manager
Route::get('/members', \Core\Mod\Tenant\View\Modal\Admin\MemberManager::class)
->name('members');
});

View file

@ -0,0 +1,370 @@
<admin:module title="{{ __('tenant::tenant.admin.member_manager.title') }}" subtitle="{{ __('tenant::tenant.admin.member_manager.subtitle') }}">
<admin:flash />
{{-- Stats cards --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<flux:icon name="users" class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ number_format($this->stats['total_members']) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.stats.total_members') }}</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
<flux:icon name="user-group" class="size-5 text-violet-600 dark:text-violet-400" />
</div>
<div>
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ number_format($this->stats['with_team']) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.stats.with_team') }}</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<flux:icon name="adjustments-horizontal" class="size-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ number_format($this->stats['with_custom_permissions']) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.stats.with_custom') }}</div>
</div>
</div>
</div>
</div>
<admin:filter-bar cols="3">
<admin:search model="search" placeholder="{{ __('tenant::tenant.admin.member_manager.search.placeholder') }}" />
<admin:filter model="workspaceFilter" :options="$this->workspaces" placeholder="{{ __('tenant::tenant.admin.member_manager.filter.all_workspaces') }}" />
<admin:filter model="teamFilter" :options="$this->teamsForFilter" placeholder="{{ __('tenant::tenant.admin.member_manager.filter.all_teams') }}" />
</admin:filter-bar>
{{-- Bulk action bar --}}
@if(count($selected) > 0)
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<flux:icon name="check-circle" class="text-blue-600 size-5" />
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
{{ __('tenant::tenant.admin.member_manager.bulk.selected', ['count' => count($selected)]) }}
</span>
</div>
<div class="flex items-center gap-2">
@if($workspaceFilter)
<flux:button wire:click="openBulkAssignModal" size="sm" variant="ghost" icon="user-group">
{{ __('tenant::tenant.admin.member_manager.bulk.assign_team') }}
</flux:button>
@endif
<flux:button wire:click="bulkRemoveFromTeam" wire:confirm="{{ __('tenant::tenant.admin.member_manager.confirm.bulk_remove_team') }}" size="sm" variant="ghost" icon="user-minus">
{{ __('tenant::tenant.admin.member_manager.bulk.remove_team') }}
</flux:button>
<flux:button wire:click="bulkClearPermissions" wire:confirm="{{ __('tenant::tenant.admin.member_manager.confirm.bulk_clear_permissions') }}" size="sm" variant="ghost" icon="shield-exclamation">
{{ __('tenant::tenant.admin.member_manager.bulk.clear_permissions') }}
</flux:button>
<flux:button wire:click="clearSelection" size="sm" variant="ghost" icon="x-mark">
{{ __('tenant::tenant.admin.member_manager.bulk.clear') }}
</flux:button>
</div>
</div>
@endif
{{-- Members table --}}
@if($this->members->isEmpty())
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
<div class="text-center py-16 px-6">
<div class="mx-auto size-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="users" class="size-8 text-zinc-400 dark:text-zinc-500" />
</div>
<flux:heading size="lg" class="text-gray-900 dark:text-gray-100">{{ __('tenant::tenant.admin.member_manager.empty_state.title') }}</flux:heading>
<flux:text class="mt-2 text-zinc-500 dark:text-zinc-400 max-w-md mx-auto">{{ __('tenant::tenant.admin.member_manager.empty_state.description') }}</flux:text>
</div>
</div>
@else
<div class="overflow-hidden rounded-lg bg-white shadow-sm dark:bg-gray-800">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left">
<flux:checkbox wire:model.live="selectAll" />
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.columns.member') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.columns.workspace') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.columns.team') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.columns.role') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.columns.permissions') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.member_manager.columns.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-800">
@foreach($this->members as $member)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
{{-- Checkbox --}}
<td class="whitespace-nowrap px-4 py-4">
<flux:checkbox wire:model.live="selected" value="{{ $member->id }}" />
</td>
{{-- Member info --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="flex items-center gap-3">
@if($member->user?->avatar_url)
<img src="{{ $member->user->avatar_url }}" alt="" class="size-8 rounded-full" />
@else
<div class="size-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<flux:icon name="user" class="size-4 text-gray-500" />
</div>
@endif
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $member->user?->name ?? __('tenant::tenant.common.unknown') }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $member->user?->email }}</div>
</div>
</div>
</td>
{{-- Workspace --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $member->workspace?->name ?? __('tenant::tenant.common.na') }}</div>
</td>
{{-- Team --}}
<td class="whitespace-nowrap px-6 py-4">
@if($member->team)
<flux:badge size="sm" color="{{ $member->team->colour }}">
{{ $member->team->name }}
</flux:badge>
@else
<span class="text-sm text-gray-400">{{ __('tenant::tenant.admin.member_manager.labels.no_team') }}</span>
@endif
</td>
{{-- Legacy role --}}
<td class="whitespace-nowrap px-6 py-4">
<span class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ $member->role }}</span>
</td>
{{-- Custom permissions indicator --}}
<td class="whitespace-nowrap px-6 py-4">
@php
$customPerms = $member->custom_permissions ?? [];
$grantCount = count(array_filter($customPerms, fn($p) => !str_starts_with($p, '-')));
$revokeCount = count(array_filter($customPerms, fn($p) => str_starts_with($p, '-')));
@endphp
@if(!empty($customPerms))
<div class="flex items-center gap-1">
@if($grantCount > 0)
<flux:badge size="sm" color="green">+{{ $grantCount }}</flux:badge>
@endif
@if($revokeCount > 0)
<flux:badge size="sm" color="red">-{{ $revokeCount }}</flux:badge>
@endif
</div>
@else
<span class="text-sm text-gray-400">{{ __('tenant::tenant.admin.member_manager.labels.inherited') }}</span>
@endif
</td>
{{-- Actions --}}
<td class="whitespace-nowrap px-6 py-4 text-center">
<flux:dropdown align="end">
<flux:button variant="ghost" icon="ellipsis-vertical" size="sm" square />
<flux:menu>
<flux:menu.item wire:click="openAssignModal({{ $member->id }})" icon="user-group">
{{ __('tenant::tenant.admin.member_manager.actions.assign_team') }}
</flux:menu.item>
<flux:menu.item wire:click="openPermissionsModal({{ $member->id }})" icon="key">
{{ __('tenant::tenant.admin.member_manager.actions.custom_permissions') }}
</flux:menu.item>
@if($member->team_id)
<flux:menu.item wire:click="removeFromTeam({{ $member->id }})" icon="user-minus">
{{ __('tenant::tenant.admin.member_manager.actions.remove_from_team') }}
</flux:menu.item>
@endif
@if(!empty($member->custom_permissions))
<flux:menu.separator />
<flux:menu.item wire:click="clearPermissions({{ $member->id }})" wire:confirm="{{ __('tenant::tenant.admin.member_manager.confirm.clear_permissions') }}" icon="shield-exclamation" variant="danger">
{{ __('tenant::tenant.admin.member_manager.actions.clear_permissions') }}
</flux:menu.item>
@endif
</flux:menu>
</flux:dropdown>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if($this->members->hasPages())
<div class="border-t border-gray-200 px-6 py-4 dark:border-gray-700">
{{ $this->members->links() }}
</div>
@endif
</div>
@endif
{{-- Assign to Team Modal --}}
<core:modal wire:model="showAssignModal" class="max-w-md">
<core:heading size="lg">
{{ __('tenant::tenant.admin.member_manager.assign_modal.title') }}
</core:heading>
<form wire:submit="saveAssignment" class="space-y-4 mt-4">
<core:select
wire:model="assignTeamId"
label="{{ __('tenant::tenant.admin.member_manager.assign_modal.team') }}"
>
<option value="">{{ __('tenant::tenant.admin.member_manager.assign_modal.no_team') }}</option>
@foreach($this->teamsForAssignment as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</core:select>
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeAssignModal">
{{ __('tenant::tenant.admin.member_manager.modal.actions.cancel') }}
</core:button>
<core:button type="submit" variant="primary">
{{ __('tenant::tenant.admin.member_manager.modal.actions.save') }}
</core:button>
</div>
</form>
</core:modal>
{{-- Custom Permissions Modal --}}
<core:modal wire:model="showPermissionsModal" class="max-w-3xl">
<core:heading size="lg">
{{ __('tenant::tenant.admin.member_manager.permissions_modal.title') }}
</core:heading>
@if($this->memberForPermissions)
<div class="mt-4 space-y-6">
{{-- Member info --}}
<div class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
@if($this->memberForPermissions->user?->avatar_url)
<img src="{{ $this->memberForPermissions->user->avatar_url }}" alt="" class="size-10 rounded-full" />
@else
<div class="size-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<flux:icon name="user" class="size-5 text-gray-500" />
</div>
@endif
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $this->memberForPermissions->user?->name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ __('tenant::tenant.admin.member_manager.permissions_modal.team_permissions', ['team' => $this->memberForPermissions->team?->name ?? __('tenant::tenant.common.none')]) }}
</div>
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg">
<flux:icon name="information-circle" class="inline size-4 mr-1" />
{{ __('tenant::tenant.admin.member_manager.permissions_modal.description') }}
</div>
<form wire:submit="savePermissions" class="space-y-6">
{{-- Granted permissions (additions) --}}
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<flux:icon name="plus-circle" class="inline size-4 mr-1 text-green-600" />
{{ __('tenant::tenant.admin.member_manager.permissions_modal.grant_label') }}
</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-48 overflow-y-auto pr-2">
@foreach($this->permissionGroups as $groupKey => $group)
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 border border-green-200 dark:border-green-800">
<div class="font-medium text-green-900 dark:text-green-100 mb-2 text-sm">{{ $group['label'] }}</div>
<div class="space-y-1.5">
@foreach($group['permissions'] as $permKey => $permLabel)
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
wire:model="grantedPermissions"
value="{{ $permKey }}"
class="rounded border-green-300 text-green-600 focus:ring-green-500"
/>
<span class="text-xs text-green-800 dark:text-green-200">{{ $permLabel }}</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
{{-- Revoked permissions (removals) --}}
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<flux:icon name="minus-circle" class="inline size-4 mr-1 text-red-600" />
{{ __('tenant::tenant.admin.member_manager.permissions_modal.revoke_label') }}
</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-48 overflow-y-auto pr-2">
@foreach($this->permissionGroups as $groupKey => $group)
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 border border-red-200 dark:border-red-800">
<div class="font-medium text-red-900 dark:text-red-100 mb-2 text-sm">{{ $group['label'] }}</div>
<div class="space-y-1.5">
@foreach($group['permissions'] as $permKey => $permLabel)
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
wire:model="revokedPermissions"
value="{{ $permKey }}"
class="rounded border-red-300 text-red-600 focus:ring-red-500"
/>
<span class="text-xs text-red-800 dark:text-red-200">{{ $permLabel }}</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closePermissionsModal">
{{ __('tenant::tenant.admin.member_manager.modal.actions.cancel') }}
</core:button>
<core:button type="submit" variant="primary">
{{ __('tenant::tenant.admin.member_manager.modal.actions.save') }}
</core:button>
</div>
</form>
</div>
@endif
</core:modal>
{{-- Bulk Assign Modal --}}
<core:modal wire:model="showBulkAssignModal" class="max-w-md">
<core:heading size="lg">
{{ __('tenant::tenant.admin.member_manager.bulk_assign_modal.title') }}
</core:heading>
<form wire:submit="bulkAssignTeam" class="space-y-4 mt-4">
<div class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg text-sm text-blue-700 dark:text-blue-300">
{{ __('tenant::tenant.admin.member_manager.bulk_assign_modal.description', ['count' => count($selected)]) }}
</div>
<core:select
wire:model="bulkTeamId"
label="{{ __('tenant::tenant.admin.member_manager.bulk_assign_modal.team') }}"
>
<option value="">{{ __('tenant::tenant.admin.member_manager.bulk_assign_modal.no_team') }}</option>
@foreach($this->teamsForBulkAssignment as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</core:select>
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeBulkAssignModal">
{{ __('tenant::tenant.admin.member_manager.modal.actions.cancel') }}
</core:button>
<core:button type="submit" variant="primary">
{{ __('tenant::tenant.admin.member_manager.modal.actions.assign') }}
</core:button>
</div>
</form>
</core:modal>
</admin:module>

View file

@ -0,0 +1,276 @@
<admin:module title="{{ __('tenant::tenant.admin.team_manager.title') }}" subtitle="{{ __('tenant::tenant.admin.team_manager.subtitle') }}">
<admin:flash />
<x-slot:actions>
<div class="flex items-center gap-2">
@if($workspaceFilter)
<flux:dropdown>
<flux:button variant="ghost" icon="ellipsis-vertical" />
<flux:menu>
<flux:menu.item wire:click="seedDefaultTeams({{ $workspaceFilter }})" icon="sparkles">
{{ __('tenant::tenant.admin.team_manager.actions.seed_defaults') }}
</flux:menu.item>
<flux:menu.item wire:click="migrateMembers({{ $workspaceFilter }})" icon="arrow-right-arrow-left">
{{ __('tenant::tenant.admin.team_manager.actions.migrate_members') }}
</flux:menu.item>
</flux:menu>
</flux:dropdown>
@endif
<flux:button wire:click="openCreateTeam" variant="primary" icon="plus">
{{ __('tenant::tenant.admin.team_manager.actions.create_team') }}
</flux:button>
</div>
</x-slot:actions>
{{-- Stats cards --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
<flux:icon name="users" class="size-5 text-violet-600 dark:text-violet-400" />
</div>
<div>
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ number_format($this->stats['total_teams']) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.stats.total_teams') }}</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<flux:icon name="user" class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ number_format($this->stats['total_members']) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.stats.total_members') }}</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<flux:icon name="check-circle" class="size-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ number_format($this->stats['members_with_team']) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.stats.members_assigned') }}</div>
</div>
</div>
</div>
</div>
<admin:filter-bar cols="2">
<admin:search model="search" placeholder="{{ __('tenant::tenant.admin.team_manager.search.placeholder') }}" />
<admin:filter model="workspaceFilter" :options="$this->workspaces" placeholder="{{ __('tenant::tenant.admin.team_manager.filter.all_workspaces') }}" />
</admin:filter-bar>
{{-- Teams table --}}
@if($this->teams->isEmpty())
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
<div class="text-center py-16 px-6">
<div class="mx-auto size-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="users" class="size-8 text-zinc-400 dark:text-zinc-500" />
</div>
<flux:heading size="lg" class="text-gray-900 dark:text-gray-100">{{ __('tenant::tenant.admin.team_manager.empty_state.title') }}</flux:heading>
<flux:text class="mt-2 text-zinc-500 dark:text-zinc-400 max-w-md mx-auto">{{ __('tenant::tenant.admin.team_manager.empty_state.description') }}</flux:text>
<div class="mt-6 flex items-center justify-center gap-3">
<flux:button wire:click="openCreateTeam" variant="primary" icon="plus">
{{ __('tenant::tenant.admin.team_manager.actions.create_team') }}
</flux:button>
@if($workspaceFilter)
<flux:button wire:click="seedDefaultTeams({{ $workspaceFilter }})" variant="ghost" icon="sparkles">
{{ __('tenant::tenant.admin.team_manager.actions.seed_defaults') }}
</flux:button>
@endif
</div>
</div>
</div>
@else
<div class="overflow-hidden rounded-lg bg-white shadow-sm dark:bg-gray-800">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.columns.team') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.columns.workspace') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.columns.members') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.columns.permissions') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ __('tenant::tenant.admin.team_manager.columns.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-800">
@foreach($this->teams as $team)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
{{-- Team info --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="flex items-center gap-3">
<div class="size-8 rounded-lg bg-{{ $team->colour }}-500/20 flex items-center justify-center">
<flux:icon name="users" class="size-4 text-{{ $team->colour }}-600 dark:text-{{ $team->colour }}-400" />
</div>
<div class="space-y-0.5">
<div class="font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
{{ $team->name }}
@if($team->is_system)
<flux:badge size="sm" color="violet">{{ __('tenant::tenant.admin.team_manager.badges.system') }}</flux:badge>
@endif
@if($team->is_default)
<flux:badge size="sm" color="blue">{{ __('tenant::tenant.admin.team_manager.badges.default') }}</flux:badge>
@endif
</div>
@if($team->description)
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">{{ $team->description }}</div>
@endif
</div>
</div>
</td>
{{-- Workspace --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $team->workspace?->name ?? __('tenant::tenant.common.na') }}</div>
</td>
{{-- Member count --}}
<td class="whitespace-nowrap px-6 py-4 text-center">
<flux:badge size="sm" color="{{ $team->colour }}">{{ number_format($team->members_count) }}</flux:badge>
</td>
{{-- Permissions count --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ count($team->permissions ?? []) }} {{ __('tenant::tenant.admin.team_manager.labels.permissions') }}
</div>
</td>
{{-- Actions --}}
<td class="whitespace-nowrap px-6 py-4 text-center">
<flux:dropdown align="end">
<flux:button variant="ghost" icon="ellipsis-vertical" size="sm" square />
<flux:menu>
<flux:menu.item wire:click="openEditTeam({{ $team->id }})" icon="pencil">
{{ __('tenant::tenant.admin.team_manager.actions.edit') }}
</flux:menu.item>
<flux:menu.item href="{{ route('hub.admin.tenant.members', ['workspaceFilter' => $team->workspace_id, 'teamFilter' => $team->id]) }}" icon="users">
{{ __('tenant::tenant.admin.team_manager.actions.view_members') }}
</flux:menu.item>
@unless($team->is_system)
<flux:menu.separator />
<flux:menu.item wire:click="deleteTeam({{ $team->id }})" wire:confirm="{{ __('tenant::tenant.admin.team_manager.confirm.delete_team') }}" icon="trash" variant="danger">
{{ __('tenant::tenant.admin.team_manager.actions.delete') }}
</flux:menu.item>
@endunless
</flux:menu>
</flux:dropdown>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if($this->teams->hasPages())
<div class="border-t border-gray-200 px-6 py-4 dark:border-gray-700">
{{ $this->teams->links() }}
</div>
@endif
</div>
@endif
{{-- Create/Edit Team Modal --}}
<core:modal wire:model="showTeamModal" class="max-w-2xl">
<core:heading size="lg">
{{ $editingTeamId ? __('tenant::tenant.admin.team_manager.modal.title_edit') : __('tenant::tenant.admin.team_manager.modal.title_create') }}
</core:heading>
<form wire:submit="saveTeam" class="space-y-4 mt-4">
<core:select
wire:model="teamWorkspaceId"
label="{{ __('tenant::tenant.admin.team_manager.modal.fields.workspace') }}"
required
>
<option value="">{{ __('tenant::tenant.admin.team_manager.modal.fields.select_workspace') }}</option>
@foreach($this->workspaces as $workspace)
<option value="{{ $workspace->id }}">{{ $workspace->name }}</option>
@endforeach
</core:select>
<div class="grid grid-cols-2 gap-4">
<core:input
wire:model="teamName"
label="{{ __('tenant::tenant.admin.team_manager.modal.fields.name') }}"
placeholder="{{ __('tenant::tenant.admin.team_manager.modal.fields.name_placeholder') }}"
required
/>
@unless($editingTeamId)
<core:input
wire:model="teamSlug"
label="{{ __('tenant::tenant.admin.team_manager.modal.fields.slug') }}"
placeholder="{{ __('tenant::tenant.admin.team_manager.modal.fields.slug_placeholder') }}"
description="{{ __('tenant::tenant.admin.team_manager.modal.fields.slug_description') }}"
/>
@endunless
</div>
<core:textarea
wire:model="teamDescription"
label="{{ __('tenant::tenant.admin.team_manager.modal.fields.description') }}"
rows="2"
/>
<div class="grid grid-cols-2 gap-4">
<core:select
wire:model="teamColour"
label="{{ __('tenant::tenant.admin.team_manager.modal.fields.colour') }}"
>
@foreach($this->colourOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</core:select>
<div class="flex items-end pb-1">
<core:checkbox
wire:model="teamIsDefault"
label="{{ __('tenant::tenant.admin.team_manager.modal.fields.is_default') }}"
/>
</div>
</div>
{{-- Permissions matrix --}}
<div class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('tenant::tenant.admin.team_manager.modal.fields.permissions') }}
</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-72 overflow-y-auto pr-2">
@foreach($this->permissionGroups as $groupKey => $group)
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3">
<div class="font-medium text-gray-900 dark:text-gray-100 mb-2 text-sm">{{ $group['label'] }}</div>
<div class="space-y-2">
@foreach($group['permissions'] as $permKey => $permLabel)
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
wire:model="teamPermissions"
value="{{ $permKey }}"
class="rounded border-gray-300 text-violet-600 focus:ring-violet-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ $permLabel }}</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeTeamModal">
{{ __('tenant::tenant.admin.team_manager.modal.actions.cancel') }}
</core:button>
<core:button type="submit" variant="primary">
{{ $editingTeamId ? __('tenant::tenant.admin.team_manager.modal.actions.update') : __('tenant::tenant.admin.team_manager.modal.actions.create') }}
</core:button>
</div>
</form>
</core:modal>
</admin:module>

View file

@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Tenant\View\Modal\Admin;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspaceMember;
use Core\Mod\Tenant\Models\WorkspaceTeam;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
class MemberManager extends Component
{
use WithPagination;
// Filters
public string $search = '';
public ?int $workspaceFilter = null;
public ?int $teamFilter = null;
// Assign to team modal
public bool $showAssignModal = false;
public ?int $assignMemberId = null;
public ?int $assignTeamId = null;
// Custom permissions modal
public bool $showPermissionsModal = false;
public ?int $permissionsMemberId = null;
public array $grantedPermissions = [];
public array $revokedPermissions = [];
// Bulk selection
public array $selected = [];
public bool $selectAll = false;
// Bulk assign modal
public bool $showBulkAssignModal = false;
public ?int $bulkTeamId = null;
public function mount(?int $workspaceFilter = null, ?int $teamFilter = null): void
{
$this->checkHadesAccess();
$this->workspaceFilter = $workspaceFilter;
$this->teamFilter = $teamFilter;
}
public function updatingSearch(): void
{
$this->resetPage();
$this->clearSelection();
}
public function updatingWorkspaceFilter(): void
{
$this->resetPage();
$this->clearSelection();
$this->teamFilter = null;
}
public function updatingTeamFilter(): void
{
$this->resetPage();
$this->clearSelection();
}
public function updatedSelectAll(bool $value): void
{
$this->selected = $value
? $this->members->pluck('id')->map(fn ($id) => (string) $id)->toArray()
: [];
}
public function clearSelection(): void
{
$this->selected = [];
$this->selectAll = false;
}
// ─────────────────────────────────────────────────────────────────────────
// Team Assignment
// ─────────────────────────────────────────────────────────────────────────
public function openAssignModal(int $memberId): void
{
$member = WorkspaceMember::findOrFail($memberId);
$this->assignMemberId = $memberId;
$this->assignTeamId = $member->team_id;
$this->showAssignModal = true;
}
public function saveAssignment(): void
{
$member = WorkspaceMember::findOrFail($this->assignMemberId);
// Validate team belongs to same workspace
if ($this->assignTeamId) {
$team = WorkspaceTeam::where('id', $this->assignTeamId)
->where('workspace_id', $member->workspace_id)
->first();
if (! $team) {
session()->flash('error', __('tenant::tenant.admin.member_manager.messages.invalid_team'));
return;
}
}
$member->update(['team_id' => $this->assignTeamId]);
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.team_assigned'));
$this->closeAssignModal();
}
public function removeFromTeam(int $memberId): void
{
$member = WorkspaceMember::findOrFail($memberId);
$member->update(['team_id' => null]);
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.removed_from_team'));
}
public function closeAssignModal(): void
{
$this->showAssignModal = false;
$this->assignMemberId = null;
$this->assignTeamId = null;
}
// ─────────────────────────────────────────────────────────────────────────
// Custom Permissions
// ─────────────────────────────────────────────────────────────────────────
public function openPermissionsModal(int $memberId): void
{
$member = WorkspaceMember::findOrFail($memberId);
$this->permissionsMemberId = $memberId;
$this->grantedPermissions = [];
$this->revokedPermissions = [];
// Parse existing custom permissions
foreach ($member->custom_permissions ?? [] as $permission) {
if (str_starts_with($permission, '-')) {
$this->revokedPermissions[] = substr($permission, 1);
} elseif (str_starts_with($permission, '+')) {
$this->grantedPermissions[] = substr($permission, 1);
} else {
$this->grantedPermissions[] = $permission;
}
}
$this->showPermissionsModal = true;
}
public function savePermissions(): void
{
$member = WorkspaceMember::findOrFail($this->permissionsMemberId);
// Build custom permissions array
$customPermissions = [];
foreach ($this->grantedPermissions as $permission) {
$customPermissions[] = '+'.$permission;
}
foreach ($this->revokedPermissions as $permission) {
$customPermissions[] = '-'.$permission;
}
$member->update([
'custom_permissions' => ! empty($customPermissions) ? $customPermissions : null,
]);
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.permissions_updated'));
$this->closePermissionsModal();
}
public function clearPermissions(int $memberId): void
{
$member = WorkspaceMember::findOrFail($memberId);
$member->update(['custom_permissions' => null]);
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.permissions_cleared'));
}
public function closePermissionsModal(): void
{
$this->showPermissionsModal = false;
$this->permissionsMemberId = null;
$this->grantedPermissions = [];
$this->revokedPermissions = [];
}
// ─────────────────────────────────────────────────────────────────────────
// Bulk Operations
// ─────────────────────────────────────────────────────────────────────────
public function openBulkAssignModal(): void
{
$this->bulkTeamId = null;
$this->showBulkAssignModal = true;
}
public function closeBulkAssignModal(): void
{
$this->showBulkAssignModal = false;
$this->bulkTeamId = null;
}
public function bulkAssignTeam(): void
{
if (empty($this->selected)) {
session()->flash('error', __('tenant::tenant.admin.member_manager.messages.no_members_selected'));
return;
}
// Validate team exists
if ($this->bulkTeamId) {
$team = WorkspaceTeam::find($this->bulkTeamId);
if (! $team) {
session()->flash('error', __('tenant::tenant.admin.member_manager.messages.invalid_team'));
return;
}
// Update only members from same workspace as the team
$updated = WorkspaceMember::whereIn('id', $this->selected)
->where('workspace_id', $team->workspace_id)
->update(['team_id' => $this->bulkTeamId]);
} else {
// Remove from teams
$updated = WorkspaceMember::whereIn('id', $this->selected)
->update(['team_id' => null]);
}
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.bulk_team_assigned', ['count' => $updated]));
$this->closeBulkAssignModal();
$this->clearSelection();
}
public function bulkRemoveFromTeam(): void
{
if (empty($this->selected)) {
session()->flash('error', __('tenant::tenant.admin.member_manager.messages.no_members_selected'));
return;
}
$updated = WorkspaceMember::whereIn('id', $this->selected)
->update(['team_id' => null]);
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.bulk_removed_from_team', ['count' => $updated]));
$this->clearSelection();
}
public function bulkClearPermissions(): void
{
if (empty($this->selected)) {
session()->flash('error', __('tenant::tenant.admin.member_manager.messages.no_members_selected'));
return;
}
$updated = WorkspaceMember::whereIn('id', $this->selected)
->update(['custom_permissions' => null]);
session()->flash('message', __('tenant::tenant.admin.member_manager.messages.bulk_permissions_cleared', ['count' => $updated]));
$this->clearSelection();
}
// ─────────────────────────────────────────────────────────────────────────
// Computed Properties
// ─────────────────────────────────────────────────────────────────────────
#[Computed]
public function members(): \Illuminate\Contracts\Pagination\LengthAwarePaginator
{
return WorkspaceMember::query()
->with(['user', 'workspace', 'team', 'inviter'])
->when($this->search, function ($query) {
$query->whereHas('user', function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
});
})
->when($this->workspaceFilter, function ($query) {
$query->where('workspace_id', $this->workspaceFilter);
})
->when($this->teamFilter, function ($query) {
$query->where('team_id', $this->teamFilter);
})
->orderBy('created_at', 'desc')
->paginate(20);
}
#[Computed]
public function workspaces(): \Illuminate\Database\Eloquent\Collection
{
return Workspace::orderBy('name')->get();
}
#[Computed]
public function teamsForFilter(): \Illuminate\Database\Eloquent\Collection
{
$query = WorkspaceTeam::query();
if ($this->workspaceFilter) {
$query->where('workspace_id', $this->workspaceFilter);
}
return $query->ordered()->get();
}
#[Computed]
public function teamsForAssignment(): \Illuminate\Database\Eloquent\Collection
{
if ($this->assignMemberId) {
$member = WorkspaceMember::find($this->assignMemberId);
if ($member) {
return WorkspaceTeam::where('workspace_id', $member->workspace_id)
->ordered()
->get();
}
}
return new \Illuminate\Database\Eloquent\Collection;
}
#[Computed]
public function teamsForBulkAssignment(): \Illuminate\Database\Eloquent\Collection
{
// Only show teams from the current workspace filter
if ($this->workspaceFilter) {
return WorkspaceTeam::where('workspace_id', $this->workspaceFilter)
->ordered()
->get();
}
return new \Illuminate\Database\Eloquent\Collection;
}
#[Computed]
public function permissionGroups(): array
{
return WorkspaceTeam::getAvailablePermissions();
}
#[Computed]
public function memberForPermissions(): ?WorkspaceMember
{
if ($this->permissionsMemberId) {
return WorkspaceMember::with(['team'])->find($this->permissionsMemberId);
}
return null;
}
#[Computed]
public function stats(): array
{
$query = WorkspaceMember::query();
if ($this->workspaceFilter) {
$query->where('workspace_id', $this->workspaceFilter);
}
if ($this->teamFilter) {
$query->where('team_id', $this->teamFilter);
}
return [
'total_members' => (clone $query)->count(),
'with_team' => (clone $query)->whereNotNull('team_id')->count(),
'with_custom_permissions' => (clone $query)->whereNotNull('custom_permissions')->count(),
];
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, __('tenant::tenant.errors.hades_required'));
}
}
public function render(): View
{
return view('tenant::admin.member-manager')
->layout('hub::admin.layouts.app', ['title' => __('tenant::tenant.admin.member_manager.title')]);
}
}

View file

@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Tenant\View\Modal\Admin;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspaceMember;
use Core\Mod\Tenant\Models\WorkspaceTeam;
use Core\Mod\Tenant\Services\WorkspaceTeamService;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
class TeamManager extends Component
{
use WithPagination;
// Filters
public string $search = '';
public ?int $workspaceFilter = null;
// Team modal
public bool $showTeamModal = false;
public ?int $editingTeamId = null;
// Team form fields
public string $teamName = '';
public string $teamSlug = '';
public string $teamDescription = '';
public array $teamPermissions = [];
public bool $teamIsDefault = false;
public string $teamColour = 'zinc';
public ?int $teamWorkspaceId = null;
// Bulk selection
public array $selected = [];
public bool $selectAll = false;
public function mount(): void
{
$this->checkHadesAccess();
}
public function updatingSearch(): void
{
$this->resetPage();
$this->clearSelection();
}
public function updatingWorkspaceFilter(): void
{
$this->resetPage();
$this->clearSelection();
}
public function updatedSelectAll(bool $value): void
{
$this->selected = $value
? $this->teams->pluck('id')->map(fn ($id) => (string) $id)->toArray()
: [];
}
public function clearSelection(): void
{
$this->selected = [];
$this->selectAll = false;
}
// ─────────────────────────────────────────────────────────────────────────
// Team CRUD
// ─────────────────────────────────────────────────────────────────────────
public function openCreateTeam(): void
{
$this->resetTeamForm();
$this->showTeamModal = true;
}
public function openEditTeam(int $id): void
{
$team = WorkspaceTeam::findOrFail($id);
$this->editingTeamId = $id;
$this->teamName = $team->name;
$this->teamSlug = $team->slug ?? '';
$this->teamDescription = $team->description ?? '';
$this->teamPermissions = $team->permissions ?? [];
$this->teamIsDefault = $team->is_default;
$this->teamColour = $team->colour ?? 'zinc';
$this->teamWorkspaceId = $team->workspace_id;
$this->showTeamModal = true;
}
public function saveTeam(): void
{
$this->validate([
'teamName' => ['required', 'string', 'max:255'],
'teamSlug' => ['nullable', 'string', 'max:255', 'alpha_dash'],
'teamDescription' => ['nullable', 'string', 'max:1000'],
'teamPermissions' => ['array'],
'teamIsDefault' => ['boolean'],
'teamColour' => ['required', 'string', 'max:32'],
'teamWorkspaceId' => ['required', 'exists:workspaces,id'],
]);
$data = [
'name' => $this->teamName,
'description' => $this->teamDescription ?: null,
'permissions' => $this->teamPermissions,
'is_default' => $this->teamIsDefault,
'colour' => $this->teamColour,
'workspace_id' => $this->teamWorkspaceId,
];
// Only set slug for new teams or if explicitly provided
if (! $this->editingTeamId && $this->teamSlug) {
$data['slug'] = $this->teamSlug;
}
// If setting as default, unset other defaults for this workspace
if ($this->teamIsDefault) {
WorkspaceTeam::where('workspace_id', $this->teamWorkspaceId)
->where('is_default', true)
->when($this->editingTeamId, fn ($q) => $q->where('id', '!=', $this->editingTeamId))
->update(['is_default' => false]);
}
if ($this->editingTeamId) {
$team = WorkspaceTeam::findOrFail($this->editingTeamId);
// Don't allow editing system team slug
if ($team->is_system) {
unset($data['slug']);
}
$team->update($data);
session()->flash('message', __('tenant::tenant.admin.team_manager.messages.team_updated'));
} else {
WorkspaceTeam::create($data);
session()->flash('message', __('tenant::tenant.admin.team_manager.messages.team_created'));
}
$this->closeTeamModal();
}
public function deleteTeam(int $id): void
{
$team = WorkspaceTeam::findOrFail($id);
if ($team->is_system) {
session()->flash('error', __('tenant::tenant.admin.team_manager.messages.cannot_delete_system'));
return;
}
$memberCount = WorkspaceMember::where('team_id', $team->id)->count();
if ($memberCount > 0) {
session()->flash('error', __('tenant::tenant.admin.team_manager.messages.cannot_delete_has_members', ['count' => $memberCount]));
return;
}
$team->delete();
session()->flash('message', __('tenant::tenant.admin.team_manager.messages.team_deleted'));
}
public function closeTeamModal(): void
{
$this->showTeamModal = false;
$this->resetTeamForm();
}
protected function resetTeamForm(): void
{
$this->editingTeamId = null;
$this->teamName = '';
$this->teamSlug = '';
$this->teamDescription = '';
$this->teamPermissions = [];
$this->teamIsDefault = false;
$this->teamColour = 'zinc';
$this->teamWorkspaceId = null;
}
public function seedDefaultTeams(int $workspaceId): void
{
$workspace = Workspace::findOrFail($workspaceId);
$teamService = app(WorkspaceTeamService::class)->forWorkspace($workspace);
$teamService->seedDefaultTeams();
session()->flash('message', __('tenant::tenant.admin.team_manager.messages.defaults_seeded'));
}
public function migrateMembers(int $workspaceId): void
{
$workspace = Workspace::findOrFail($workspaceId);
$teamService = app(WorkspaceTeamService::class)->forWorkspace($workspace);
$migrated = $teamService->migrateExistingMembers();
session()->flash('message', __('tenant::tenant.admin.team_manager.messages.members_migrated', ['count' => $migrated]));
}
// ─────────────────────────────────────────────────────────────────────────
// Computed Properties
// ─────────────────────────────────────────────────────────────────────────
#[Computed]
public function teams(): \Illuminate\Contracts\Pagination\LengthAwarePaginator
{
return WorkspaceTeam::query()
->with(['workspace'])
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('description', 'like', "%{$this->search}%");
});
})
->when($this->workspaceFilter, function ($query) {
$query->where('workspace_id', $this->workspaceFilter);
})
->withCount('members')
->orderBy('workspace_id')
->ordered()
->paginate(20);
}
#[Computed]
public function workspaces(): \Illuminate\Database\Eloquent\Collection
{
return Workspace::orderBy('name')->get();
}
#[Computed]
public function permissionGroups(): array
{
return WorkspaceTeam::getAvailablePermissions();
}
#[Computed]
public function colourOptions(): array
{
return WorkspaceTeam::getColourOptions();
}
#[Computed]
public function stats(): array
{
$teamQuery = WorkspaceTeam::query();
$memberQuery = WorkspaceMember::query();
if ($this->workspaceFilter) {
$teamQuery->where('workspace_id', $this->workspaceFilter);
$memberQuery->where('workspace_id', $this->workspaceFilter);
}
return [
'total_teams' => $teamQuery->count(),
'total_members' => $memberQuery->count(),
'members_with_team' => (clone $memberQuery)->whereNotNull('team_id')->count(),
];
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, __('tenant::tenant.errors.hades_required'));
}
}
public function render(): View
{
return view('tenant::admin.team-manager')
->layout('hub::admin.layouts.app', ['title' => __('tenant::tenant.admin.team_manager.title')]);
}
}