From 68525ca2478ab5c8ec556d737b56b7fe823402d0 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 27 Jan 2026 10:21:25 +0000 Subject: [PATCH] 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 --- Boot.php | 6 + Routes/admin.php | 24 ++ View/Blade/admin/member-manager.blade.php | 370 ++++++++++++++++++++ View/Blade/admin/team-manager.blade.php | 276 +++++++++++++++ View/Modal/Admin/MemberManager.php | 404 ++++++++++++++++++++++ View/Modal/Admin/TeamManager.php | 289 ++++++++++++++++ 6 files changed, 1369 insertions(+) create mode 100644 Routes/admin.php create mode 100644 View/Blade/admin/member-manager.blade.php create mode 100644 View/Blade/admin/team-manager.blade.php create mode 100644 View/Modal/Admin/MemberManager.php create mode 100644 View/Modal/Admin/TeamManager.php diff --git a/Boot.php b/Boot.php index 354c780..2b5e4d6 100644 --- a/Boot.php +++ b/Boot.php @@ -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 diff --git a/Routes/admin.php b/Routes/admin.php new file mode 100644 index 0000000..67dfddc --- /dev/null +++ b/Routes/admin.php @@ -0,0 +1,24 @@ +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'); +}); diff --git a/View/Blade/admin/member-manager.blade.php b/View/Blade/admin/member-manager.blade.php new file mode 100644 index 0000000..ab53310 --- /dev/null +++ b/View/Blade/admin/member-manager.blade.php @@ -0,0 +1,370 @@ + + + + {{-- Stats cards --}} +
+
+
+
+ +
+
+
{{ number_format($this->stats['total_members']) }}
+
{{ __('tenant::tenant.admin.member_manager.stats.total_members') }}
+
+
+
+
+
+
+ +
+
+
{{ number_format($this->stats['with_team']) }}
+
{{ __('tenant::tenant.admin.member_manager.stats.with_team') }}
+
+
+
+
+
+
+ +
+
+
{{ number_format($this->stats['with_custom_permissions']) }}
+
{{ __('tenant::tenant.admin.member_manager.stats.with_custom') }}
+
+
+
+
+ + + + + + + + {{-- Bulk action bar --}} + @if(count($selected) > 0) +
+
+ + + {{ __('tenant::tenant.admin.member_manager.bulk.selected', ['count' => count($selected)]) }} + +
+
+ @if($workspaceFilter) + + {{ __('tenant::tenant.admin.member_manager.bulk.assign_team') }} + + @endif + + {{ __('tenant::tenant.admin.member_manager.bulk.remove_team') }} + + + {{ __('tenant::tenant.admin.member_manager.bulk.clear_permissions') }} + + + {{ __('tenant::tenant.admin.member_manager.bulk.clear') }} + +
+
+ @endif + + {{-- Members table --}} + @if($this->members->isEmpty()) +
+
+
+ +
+ {{ __('tenant::tenant.admin.member_manager.empty_state.title') }} + {{ __('tenant::tenant.admin.member_manager.empty_state.description') }} +
+
+ @else +
+
+ + + + + + + + + + + + + + @foreach($this->members as $member) + + {{-- Checkbox --}} + + + {{-- Member info --}} + + + {{-- Workspace --}} + + + {{-- Team --}} + + + {{-- Legacy role --}} + + + {{-- Custom permissions indicator --}} + + + {{-- Actions --}} + + + @endforeach + +
+ + {{ __('tenant::tenant.admin.member_manager.columns.member') }}{{ __('tenant::tenant.admin.member_manager.columns.workspace') }}{{ __('tenant::tenant.admin.member_manager.columns.team') }}{{ __('tenant::tenant.admin.member_manager.columns.role') }}{{ __('tenant::tenant.admin.member_manager.columns.permissions') }}{{ __('tenant::tenant.admin.member_manager.columns.actions') }}
+ + +
+ @if($member->user?->avatar_url) + + @else +
+ +
+ @endif +
+
{{ $member->user?->name ?? __('tenant::tenant.common.unknown') }}
+
{{ $member->user?->email }}
+
+
+
+
{{ $member->workspace?->name ?? __('tenant::tenant.common.na') }}
+
+ @if($member->team) + + {{ $member->team->name }} + + @else + {{ __('tenant::tenant.admin.member_manager.labels.no_team') }} + @endif + + {{ $member->role }} + + @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)) +
+ @if($grantCount > 0) + +{{ $grantCount }} + @endif + @if($revokeCount > 0) + -{{ $revokeCount }} + @endif +
+ @else + {{ __('tenant::tenant.admin.member_manager.labels.inherited') }} + @endif +
+ + + + + {{ __('tenant::tenant.admin.member_manager.actions.assign_team') }} + + + {{ __('tenant::tenant.admin.member_manager.actions.custom_permissions') }} + + @if($member->team_id) + + {{ __('tenant::tenant.admin.member_manager.actions.remove_from_team') }} + + @endif + @if(!empty($member->custom_permissions)) + + + {{ __('tenant::tenant.admin.member_manager.actions.clear_permissions') }} + + @endif + + +
+
+ + @if($this->members->hasPages()) +
+ {{ $this->members->links() }} +
+ @endif +
+ @endif + + {{-- Assign to Team Modal --}} + + + {{ __('tenant::tenant.admin.member_manager.assign_modal.title') }} + + +
+ + + @foreach($this->teamsForAssignment as $team) + + @endforeach + + +
+ + {{ __('tenant::tenant.admin.member_manager.modal.actions.cancel') }} + + + {{ __('tenant::tenant.admin.member_manager.modal.actions.save') }} + +
+
+
+ + {{-- Custom Permissions Modal --}} + + + {{ __('tenant::tenant.admin.member_manager.permissions_modal.title') }} + + + @if($this->memberForPermissions) +
+ {{-- Member info --}} +
+ @if($this->memberForPermissions->user?->avatar_url) + + @else +
+ +
+ @endif +
+
{{ $this->memberForPermissions->user?->name }}
+
+ {{ __('tenant::tenant.admin.member_manager.permissions_modal.team_permissions', ['team' => $this->memberForPermissions->team?->name ?? __('tenant::tenant.common.none')]) }} +
+
+
+ +
+ + {{ __('tenant::tenant.admin.member_manager.permissions_modal.description') }} +
+ +
+ {{-- Granted permissions (additions) --}} +
+ + +
+ @foreach($this->permissionGroups as $groupKey => $group) +
+
{{ $group['label'] }}
+
+ @foreach($group['permissions'] as $permKey => $permLabel) + + @endforeach +
+
+ @endforeach +
+
+ + {{-- Revoked permissions (removals) --}} +
+ + +
+ @foreach($this->permissionGroups as $groupKey => $group) +
+
{{ $group['label'] }}
+
+ @foreach($group['permissions'] as $permKey => $permLabel) + + @endforeach +
+
+ @endforeach +
+
+ +
+ + {{ __('tenant::tenant.admin.member_manager.modal.actions.cancel') }} + + + {{ __('tenant::tenant.admin.member_manager.modal.actions.save') }} + +
+
+
+ @endif +
+ + {{-- Bulk Assign Modal --}} + + + {{ __('tenant::tenant.admin.member_manager.bulk_assign_modal.title') }} + + +
+
+ {{ __('tenant::tenant.admin.member_manager.bulk_assign_modal.description', ['count' => count($selected)]) }} +
+ + + + @foreach($this->teamsForBulkAssignment as $team) + + @endforeach + + +
+ + {{ __('tenant::tenant.admin.member_manager.modal.actions.cancel') }} + + + {{ __('tenant::tenant.admin.member_manager.modal.actions.assign') }} + +
+
+
+
diff --git a/View/Blade/admin/team-manager.blade.php b/View/Blade/admin/team-manager.blade.php new file mode 100644 index 0000000..ca70046 --- /dev/null +++ b/View/Blade/admin/team-manager.blade.php @@ -0,0 +1,276 @@ + + + + +
+ @if($workspaceFilter) + + + + + {{ __('tenant::tenant.admin.team_manager.actions.seed_defaults') }} + + + {{ __('tenant::tenant.admin.team_manager.actions.migrate_members') }} + + + + @endif + + {{ __('tenant::tenant.admin.team_manager.actions.create_team') }} + +
+
+ + {{-- Stats cards --}} +
+
+
+
+ +
+
+
{{ number_format($this->stats['total_teams']) }}
+
{{ __('tenant::tenant.admin.team_manager.stats.total_teams') }}
+
+
+
+
+
+
+ +
+
+
{{ number_format($this->stats['total_members']) }}
+
{{ __('tenant::tenant.admin.team_manager.stats.total_members') }}
+
+
+
+
+
+
+ +
+
+
{{ number_format($this->stats['members_with_team']) }}
+
{{ __('tenant::tenant.admin.team_manager.stats.members_assigned') }}
+
+
+
+
+ + + + + + + {{-- Teams table --}} + @if($this->teams->isEmpty()) +
+
+
+ +
+ {{ __('tenant::tenant.admin.team_manager.empty_state.title') }} + {{ __('tenant::tenant.admin.team_manager.empty_state.description') }} +
+ + {{ __('tenant::tenant.admin.team_manager.actions.create_team') }} + + @if($workspaceFilter) + + {{ __('tenant::tenant.admin.team_manager.actions.seed_defaults') }} + + @endif +
+
+
+ @else +
+
+ + + + + + + + + + + + @foreach($this->teams as $team) + + {{-- Team info --}} + + + {{-- Workspace --}} + + + {{-- Member count --}} + + + {{-- Permissions count --}} + + + {{-- Actions --}} + + + @endforeach + +
{{ __('tenant::tenant.admin.team_manager.columns.team') }}{{ __('tenant::tenant.admin.team_manager.columns.workspace') }}{{ __('tenant::tenant.admin.team_manager.columns.members') }}{{ __('tenant::tenant.admin.team_manager.columns.permissions') }}{{ __('tenant::tenant.admin.team_manager.columns.actions') }}
+
+
+ +
+
+
+ {{ $team->name }} + @if($team->is_system) + {{ __('tenant::tenant.admin.team_manager.badges.system') }} + @endif + @if($team->is_default) + {{ __('tenant::tenant.admin.team_manager.badges.default') }} + @endif +
+ @if($team->description) +
{{ $team->description }}
+ @endif +
+
+
+
{{ $team->workspace?->name ?? __('tenant::tenant.common.na') }}
+
+ {{ number_format($team->members_count) }} + +
+ {{ count($team->permissions ?? []) }} {{ __('tenant::tenant.admin.team_manager.labels.permissions') }} +
+
+ + + + + {{ __('tenant::tenant.admin.team_manager.actions.edit') }} + + + {{ __('tenant::tenant.admin.team_manager.actions.view_members') }} + + @unless($team->is_system) + + + {{ __('tenant::tenant.admin.team_manager.actions.delete') }} + + @endunless + + +
+
+ + @if($this->teams->hasPages()) +
+ {{ $this->teams->links() }} +
+ @endif +
+ @endif + + {{-- Create/Edit Team Modal --}} + + + {{ $editingTeamId ? __('tenant::tenant.admin.team_manager.modal.title_edit') : __('tenant::tenant.admin.team_manager.modal.title_create') }} + + +
+ + + @foreach($this->workspaces as $workspace) + + @endforeach + + +
+ + + @unless($editingTeamId) + + @endunless +
+ + + +
+ + @foreach($this->colourOptions as $value => $label) + + @endforeach + + +
+ +
+
+ + {{-- Permissions matrix --}} +
+ + +
+ @foreach($this->permissionGroups as $groupKey => $group) +
+
{{ $group['label'] }}
+
+ @foreach($group['permissions'] as $permKey => $permLabel) + + @endforeach +
+
+ @endforeach +
+
+ +
+ + {{ __('tenant::tenant.admin.team_manager.modal.actions.cancel') }} + + + {{ $editingTeamId ? __('tenant::tenant.admin.team_manager.modal.actions.update') : __('tenant::tenant.admin.team_manager.modal.actions.create') }} + +
+ +
+
diff --git a/View/Modal/Admin/MemberManager.php b/View/Modal/Admin/MemberManager.php new file mode 100644 index 0000000..1cc3f26 --- /dev/null +++ b/View/Modal/Admin/MemberManager.php @@ -0,0 +1,404 @@ +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')]); + } +} diff --git a/View/Modal/Admin/TeamManager.php b/View/Modal/Admin/TeamManager.php new file mode 100644 index 0000000..68b34c1 --- /dev/null +++ b/View/Modal/Admin/TeamManager.php @@ -0,0 +1,289 @@ +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')]); + } +}