php-tenant/View/Modal/Admin/WorkspaceManager.php
Snider d0ad2737cb refactor: rename namespace from Core\Mod\Tenant to Core\Tenant
Simplifies the namespace hierarchy by removing the intermediate Mod
segment. Updates all 118 files including models, services, controllers,
middleware, tests, and composer.json autoload configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:30:46 +00:00

666 lines
22 KiB
PHP

<?php
namespace Core\Tenant\View\Modal\Admin;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
class WorkspaceManager extends Component
{
use WithPagination;
// Search and filtering
public string $search = '';
// Edit modal state
public ?int $editingId = null;
public string $name = '';
public string $slug = '';
public bool $isActive = true;
// Transfer modal state
public bool $showTransferModal = false;
public ?int $sourceWorkspaceId = null;
public ?int $targetWorkspaceId = null;
public array $selectedResourceTypes = [];
// Change owner modal state
public bool $showOwnerModal = false;
public ?int $ownerWorkspaceId = null;
public ?int $newOwnerId = null;
// Resource viewer modal state
public bool $showResourcesModal = false;
public ?int $resourcesWorkspaceId = null;
public ?string $resourcesType = null;
public array $selectedResources = [];
public ?int $resourcesTargetWorkspaceId = null;
// Provision modal state
public bool $showProvisionModal = false;
public ?int $provisionWorkspaceId = null;
public ?string $provisionType = null;
public string $provisionName = '';
public string $provisionUrl = '';
public string $provisionSlug = '';
// Action messages
public string $actionMessage = '';
public string $actionType = '';
protected $queryString = [
'search' => ['except' => ''],
];
protected array $rules = [
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|alpha_dash',
'isActive' => 'boolean',
];
public function mount(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required for workspace administration.');
}
}
public function updatingSearch(): void
{
$this->resetPage();
}
#[Computed]
public function workspaces()
{
return Workspace::query()
->withCount($this->getAvailableRelations())
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('slug', 'like', "%{$this->search}%");
});
})
->orderBy('name')
->paginate(20);
}
/**
* Get relations that are available for counting.
* Filters out relations whose models don't exist yet or have incompatible schemas.
*/
protected function getAvailableRelations(): array
{
$relations = [];
// Check each relation's model exists and has workspace_id column
$checks = [
'bioPages' => ['model' => \Core\Mod\Web\Models\Page::class, 'table' => 'pages'],
'bioProjects' => ['model' => \Core\Mod\Web\Models\Project::class, 'table' => 'page_projects'],
'socialAccounts' => ['model' => \Core\Mod\Social\Models\Account::class, 'table' => 'social_accounts'],
'analyticsSites' => ['model' => \Core\Mod\Analytics\Models\Website::class, 'table' => 'analytics_websites'],
'trustWidgets' => ['model' => \Core\Mod\Trust\Models\Campaign::class, 'table' => 'trust_campaigns'],
'notificationSites' => ['model' => \Core\Mod\Notify\Models\PushWebsite::class, 'table' => 'push_websites'],
];
$schema = \Illuminate\Support\Facades\Schema::getFacadeRoot();
foreach ($checks as $relation => $info) {
if (class_exists($info['model'])) {
// Verify the table has workspace_id column
try {
if ($schema->hasColumn($info['table'], 'workspace_id')) {
$relations[] = $relation;
}
} catch (\Exception $e) {
// Table might not exist yet, skip
}
}
}
return $relations;
}
#[Computed]
public function allWorkspaces()
{
return Workspace::orderBy('name')->get(['id', 'name', 'slug']);
}
#[Computed]
public function resourceTypes(): array
{
$types = [];
$schema = \Illuminate\Support\Facades\Schema::getFacadeRoot();
// Only include resource types for models that exist and have valid relations
$checks = [
'bio_pages' => ['model' => \Core\Mod\Web\Models\Page::class, 'table' => 'pages', 'label' => 'Bio Pages', 'relation' => 'bioPages', 'icon' => 'link'],
'bio_projects' => ['model' => \Core\Mod\Web\Models\Project::class, 'table' => 'page_projects', 'label' => 'Bio Projects', 'relation' => 'bioProjects', 'icon' => 'folder'],
'social_accounts' => ['model' => \Core\Mod\Social\Models\Account::class, 'table' => 'social_accounts', 'label' => 'Social Accounts', 'relation' => 'socialAccounts', 'icon' => 'share-nodes'],
'analytics_sites' => ['model' => \Core\Mod\Analytics\Models\Website::class, 'table' => 'analytics_websites', 'label' => 'Analytics Sites', 'relation' => 'analyticsSites', 'icon' => 'chart-line'],
'trust_widgets' => ['model' => \Core\Mod\Trust\Models\Campaign::class, 'table' => 'trust_campaigns', 'label' => 'Trust Campaigns', 'relation' => 'trustWidgets', 'icon' => 'shield-check'],
'notification_sites' => ['model' => \Core\Mod\Notify\Models\PushWebsite::class, 'table' => 'push_websites', 'label' => 'Notification Sites', 'relation' => 'notificationSites', 'icon' => 'bell'],
];
foreach ($checks as $key => $info) {
if (class_exists($info['model'])) {
try {
if ($schema->hasColumn($info['table'], 'workspace_id')) {
$types[$key] = [
'label' => $info['label'],
'relation' => $info['relation'],
'icon' => $info['icon'],
];
}
} catch (\Exception $e) {
// Table might not exist yet, skip
}
}
}
return $types;
}
public function openEdit(int $id): void
{
$workspace = Workspace::findOrFail($id);
$this->editingId = $id;
$this->name = $workspace->name;
$this->slug = $workspace->slug;
$this->isActive = $workspace->is_active;
}
public function closeEdit(): void
{
$this->editingId = null;
$this->reset(['name', 'slug', 'isActive']);
$this->resetErrorBag();
}
public function save(): void
{
$this->validate();
$workspace = Workspace::findOrFail($this->editingId);
// Check if slug is unique (excluding current workspace)
$slugExists = Workspace::where('slug', $this->slug)
->where('id', '!=', $this->editingId)
->exists();
if ($slugExists) {
$this->addError('slug', 'This slug is already in use.');
return;
}
$workspace->update([
'name' => $this->name,
'slug' => $this->slug,
'is_active' => $this->isActive,
]);
$this->closeEdit();
$this->actionMessage = "Workspace '{$workspace->name}' updated successfully.";
$this->actionType = 'success';
unset($this->workspaces);
}
public function delete(int $id): void
{
$workspace = Workspace::withCount($this->getAvailableRelations())->findOrFail($id);
// Check for resources (safely get counts that might not exist)
$totalResources = ($workspace->bio_pages_count ?? 0)
+ ($workspace->bio_projects_count ?? 0)
+ ($workspace->social_accounts_count ?? 0)
+ ($workspace->analytics_sites_count ?? 0)
+ ($workspace->trust_widgets_count ?? 0)
+ ($workspace->notification_sites_count ?? 0)
+ ($workspace->orders_count ?? 0);
if ($totalResources > 0) {
$this->actionMessage = "Cannot delete workspace '{$workspace->name}'. It has {$totalResources} resources. Transfer or delete them first.";
$this->actionType = 'error';
return;
}
// Check for users
if ($workspace->users()->count() > 0) {
$this->actionMessage = "Cannot delete workspace '{$workspace->name}'. It still has users assigned.";
$this->actionType = 'error';
return;
}
$workspaceName = $workspace->name;
$workspace->delete();
$this->actionMessage = "Workspace '{$workspaceName}' deleted successfully.";
$this->actionType = 'success';
unset($this->workspaces);
}
public function openTransfer(int $workspaceId): void
{
$this->sourceWorkspaceId = $workspaceId;
$this->targetWorkspaceId = null;
$this->selectedResourceTypes = [];
$this->showTransferModal = true;
}
public function closeTransfer(): void
{
$this->showTransferModal = false;
$this->reset(['sourceWorkspaceId', 'targetWorkspaceId', 'selectedResourceTypes']);
}
public function executeTransfer(): void
{
if (! $this->sourceWorkspaceId || ! $this->targetWorkspaceId) {
$this->actionMessage = 'Please select both source and target workspaces.';
$this->actionType = 'error';
return;
}
if ($this->sourceWorkspaceId === $this->targetWorkspaceId) {
$this->actionMessage = 'Source and target workspaces cannot be the same.';
$this->actionType = 'error';
return;
}
if (empty($this->selectedResourceTypes)) {
$this->actionMessage = 'Please select at least one resource type to transfer.';
$this->actionType = 'error';
return;
}
$source = Workspace::findOrFail($this->sourceWorkspaceId);
$target = Workspace::findOrFail($this->targetWorkspaceId);
$resourceTypes = $this->resourceTypes;
$transferred = [];
DB::transaction(function () use ($source, $target, $resourceTypes, &$transferred) {
foreach ($this->selectedResourceTypes as $type) {
if (! isset($resourceTypes[$type])) {
continue;
}
$relation = $resourceTypes[$type]['relation'];
$count = $source->{$relation}()->count();
if ($count > 0) {
$source->{$relation}()->update(['workspace_id' => $target->id]);
$transferred[$resourceTypes[$type]['label']] = $count;
}
}
});
$this->closeTransfer();
if (empty($transferred)) {
$this->actionMessage = 'No resources were transferred (source had no resources of selected types).';
$this->actionType = 'warning';
} else {
$summary = collect($transferred)
->map(fn ($count, $label) => "{$count} {$label}")
->join(', ');
$this->actionMessage = "Transferred {$summary} from '{$source->name}' to '{$target->name}'.";
$this->actionType = 'success';
}
unset($this->workspaces);
}
#[Computed]
public function allUsers()
{
return User::orderBy('name')->get(['id', 'name', 'email']);
}
public function openChangeOwner(int $workspaceId): void
{
$workspace = Workspace::findOrFail($workspaceId);
$this->ownerWorkspaceId = $workspaceId;
$this->newOwnerId = $workspace->owner()?->id;
$this->showOwnerModal = true;
}
public function closeChangeOwner(): void
{
$this->showOwnerModal = false;
$this->reset(['ownerWorkspaceId', 'newOwnerId']);
}
public function changeOwner(): void
{
if (! $this->ownerWorkspaceId || ! $this->newOwnerId) {
$this->actionMessage = 'Please select a new owner.';
$this->actionType = 'error';
return;
}
$workspace = Workspace::findOrFail($this->ownerWorkspaceId);
$newOwner = User::findOrFail($this->newOwnerId);
$oldOwner = $workspace->owner();
DB::transaction(function () use ($workspace, $newOwner, $oldOwner) {
// Remove owner role from current owner (if exists)
if ($oldOwner) {
$workspace->users()->updateExistingPivot($oldOwner->id, ['role' => 'member']);
}
// Check if new owner is already a member
if ($workspace->users()->where('user_id', $newOwner->id)->exists()) {
// Update existing membership to owner
$workspace->users()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
} else {
// Add new owner to workspace
$workspace->users()->attach($newOwner->id, ['role' => 'owner']);
}
});
$this->closeChangeOwner();
$this->actionMessage = "Ownership of '{$workspace->name}' transferred to {$newOwner->name}.";
$this->actionType = 'success';
unset($this->workspaces);
}
public function openResources(int $workspaceId, string $type): void
{
$this->resourcesWorkspaceId = $workspaceId;
$this->resourcesType = $type;
$this->selectedResources = [];
$this->resourcesTargetWorkspaceId = null;
$this->showResourcesModal = true;
}
public function closeResources(): void
{
$this->showResourcesModal = false;
$this->reset(['resourcesWorkspaceId', 'resourcesType', 'selectedResources', 'resourcesTargetWorkspaceId']);
}
#[Computed]
public function currentResources(): array
{
if (! $this->resourcesWorkspaceId || ! $this->resourcesType) {
return [];
}
$resourceTypes = $this->resourceTypes;
if (! isset($resourceTypes[$this->resourcesType])) {
return [];
}
$workspace = Workspace::find($this->resourcesWorkspaceId);
if (! $workspace) {
return [];
}
$relation = $resourceTypes[$this->resourcesType]['relation'];
return $workspace->{$relation}()
->get()
->map(function ($item) {
return [
'id' => $item->id,
'name' => $item->name ?? $item->title ?? "#{$item->id}",
'detail' => $item->url ?? $item->domain ?? $item->email ?? $item->slug ?? null,
'created_at' => $item->created_at?->format('d M Y'),
];
})
->toArray();
}
public function toggleResourceSelection(int $id): void
{
if (in_array($id, $this->selectedResources)) {
$this->selectedResources = array_values(array_diff($this->selectedResources, [$id]));
} else {
$this->selectedResources[] = $id;
}
}
public function selectAllResources(): void
{
$this->selectedResources = collect($this->currentResources)->pluck('id')->toArray();
}
public function deselectAllResources(): void
{
$this->selectedResources = [];
}
public function transferSelectedResources(): void
{
if (empty($this->selectedResources)) {
$this->actionMessage = 'Please select at least one resource to transfer.';
$this->actionType = 'error';
return;
}
if (! $this->resourcesTargetWorkspaceId) {
$this->actionMessage = 'Please select a target workspace.';
$this->actionType = 'error';
return;
}
if ($this->resourcesWorkspaceId === $this->resourcesTargetWorkspaceId) {
$this->actionMessage = 'Source and target workspaces cannot be the same.';
$this->actionType = 'error';
return;
}
$resourceTypes = $this->resourceTypes;
if (! isset($resourceTypes[$this->resourcesType])) {
$this->actionMessage = 'Invalid resource type.';
$this->actionType = 'error';
return;
}
$workspace = Workspace::findOrFail($this->resourcesWorkspaceId);
$target = Workspace::findOrFail($this->resourcesTargetWorkspaceId);
$relation = $resourceTypes[$this->resourcesType]['relation'];
$label = $resourceTypes[$this->resourcesType]['label'];
$count = $workspace->{$relation}()
->whereIn('id', $this->selectedResources)
->update(['workspace_id' => $target->id]);
$this->closeResources();
$this->actionMessage = "Transferred {$count} {$label} from '{$workspace->name}' to '{$target->name}'.";
$this->actionType = 'success';
unset($this->workspaces);
}
public function openProvision(int $workspaceId, string $type): void
{
$this->provisionWorkspaceId = $workspaceId;
$this->provisionType = $type;
$this->provisionName = '';
$this->provisionUrl = '';
$this->showProvisionModal = true;
}
public function closeProvision(): void
{
$this->showProvisionModal = false;
$this->reset(['provisionWorkspaceId', 'provisionType', 'provisionName', 'provisionUrl', 'provisionSlug']);
}
#[Computed]
public function provisionConfig(): array
{
return [
'bio_pages' => [
'label' => 'Bio Page',
'icon' => 'link',
'color' => 'blue',
'fields' => ['name', 'slug'],
'model' => \Core\Mod\Web\Models\Page::class,
'defaults' => ['type' => 'page', 'is_enabled' => true],
],
'social_accounts' => [
'label' => 'Social Account',
'icon' => 'share-nodes',
'color' => 'purple',
'fields' => ['name'],
'model' => \Core\Mod\Social\Models\Account::class,
'defaults' => ['provider' => 'manual', 'status' => 'active'],
],
'analytics_sites' => [
'label' => 'Analytics Site',
'icon' => 'chart-line',
'color' => 'cyan',
'fields' => ['name', 'url'],
'model' => \Core\Mod\Analytics\Models\Website::class,
'defaults' => ['tracking_enabled' => true, 'is_enabled' => true],
],
'trust_widgets' => [
'label' => 'Trust Campaign',
'icon' => 'shield-check',
'color' => 'emerald',
'fields' => ['name'],
'model' => \Core\Mod\Trust\Models\Campaign::class,
'defaults' => ['status' => 'draft'],
],
'notification_sites' => [
'label' => 'Notification Site',
'icon' => 'bell',
'color' => 'amber',
'fields' => ['name', 'url'],
'model' => \Core\Mod\Notify\Models\PushWebsite::class,
'defaults' => ['status' => 'active'],
],
];
}
public function provisionResource(): void
{
$config = $this->provisionConfig[$this->provisionType] ?? null;
if (! $config || ! class_exists($config['model'])) {
$this->actionMessage = 'Invalid resource type or model not available.';
$this->actionType = 'error';
return;
}
if (empty($this->provisionName)) {
$this->actionMessage = 'Please enter a name.';
$this->actionType = 'error';
return;
}
if (in_array('url', $config['fields']) && empty($this->provisionUrl)) {
$this->actionMessage = 'Please enter a URL.';
$this->actionType = 'error';
return;
}
if (in_array('slug', $config['fields']) && empty($this->provisionSlug)) {
$this->actionMessage = 'Please enter a slug.';
$this->actionType = 'error';
return;
}
$workspace = Workspace::findOrFail($this->provisionWorkspaceId);
$data = array_merge($config['defaults'], [
'workspace_id' => $workspace->id,
]);
// Handle name - for bio pages it goes in settings
if ($this->provisionType === 'bio_pages') {
$data['settings'] = ['page_title' => $this->provisionName];
} else {
$data['name'] = $this->provisionName;
}
// Add slug for bio pages
if (in_array('slug', $config['fields']) && $this->provisionSlug) {
$data['url'] = \Illuminate\Support\Str::slug($this->provisionSlug);
}
// Add URL-related fields if applicable
if (in_array('url', $config['fields']) && $this->provisionUrl) {
$url = $this->provisionUrl;
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$url = 'https://'.$url;
}
$parsed = parse_url($url);
$data['url'] = $url;
$data['host'] = $parsed['host'] ?? null;
$data['scheme'] = $parsed['scheme'] ?? 'https';
}
// Add user_id if the model expects it
if (auth()->check()) {
$data['user_id'] = auth()->id();
}
try {
$config['model']::create($data);
$this->closeProvision();
$this->actionMessage = "{$config['label']} '{$this->provisionName}' created in '{$workspace->name}'.";
$this->actionType = 'success';
unset($this->workspaces);
} catch (\Exception $e) {
$this->actionMessage = "Failed to create resource: {$e->getMessage()}";
$this->actionType = 'error';
}
}
public function getStats(): array
{
return [
'total' => Workspace::count(),
'active' => Workspace::where('is_active', true)->count(),
'inactive' => Workspace::where('is_active', false)->count(),
];
}
public function render()
{
return view('tenant::admin.workspace-manager', [
'stats' => $this->getStats(),
])->layout('hub::admin.layouts.app', ['title' => 'Workspace Manager']);
}
}