*/ public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false]; /** @var array */ public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false]; /** @var array */ public array $categoryForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false]; /** @var array */ public array $teamForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null, 'user_ids' => []]; /** @var array */ public array $projectForm = ['id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null]; /** @var array */ public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []]; /** @var array */ public array $repositoryForm = ['id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; /** @var array */ public array $cannedForm = ['id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0]; /** @var array */ public array $slaForm = ['id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true]; /** @var array */ public array $automationForm = ['id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0]; /** @var array */ public array $productForm = ['id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true]; /** @var array */ public array $tagForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray']; /** @var array */ public array $checklistTemplateForm = ['id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true]; /** @var array */ public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; public function mount(): void { $this->section = $this->validSection((string) request()->query('section', $this->section)); $this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false]; $this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false]; $this->categoryForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false]; $this->teamForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null, 'user_ids' => []]; $this->projectForm += ['id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null]; $this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []]; $this->repositoryForm += ['id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; $this->workflowForm += ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; $this->cannedForm += ['id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0]; $this->slaForm += ['id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true]; $this->automationForm += ['id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0]; $this->productForm += ['id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true]; $this->tagForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray']; $this->checklistTemplateForm += ['id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true]; } /** * Keep settings section navigation constrained to known panels. */ private function validSection(string $section): string { $allowed = [ 'statuses', 'priorities', 'categories', 'teams', 'projects', 'boards', 'git', 'workflow', 'canned', 'sla', 'automation', 'checklists', 'tags', 'products', 'defaults', ]; return in_array($section, $allowed, true) ? $section : 'statuses'; } public function saveStatus(): void { $data = $this->validate([ 'statusForm.name' => ['required', 'string', 'max:80'], 'statusForm.slug' => ['nullable', 'string', 'max:100'], 'statusForm.color' => ['required', 'string', Color::validationRule()], 'statusForm.sort_order' => ['required', 'integer', 'min:0'], 'statusForm.is_closed' => ['boolean'], 'statusForm.is_default' => ['boolean'], ])['statusForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data = $this->guardDefaultColumn('helpdesk_statuses', $data); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(Status::class, $this->statusForm['id']); } $this->persistSettingModel(Status::class, $this->statusForm['id'], $data); $this->resetStatusForm(); $this->statusModalOpen = false; session()->flash('success', 'Status saved.'); } public function editStatus(int $id): void { $this->statusForm = Status::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_closed', 'is_default']); $this->statusForm['color'] = Color::normalise($this->statusForm['color'] ?? 'gray'); $this->section = 'statuses'; $this->statusModalOpen = true; } public function deleteStatus(int $id): void { Status::query()->findOrFail($id)->delete(); session()->flash('success', 'Status deleted.'); } public function openCreateStatusModal(): void { $this->resetValidation(); $this->resetStatusForm(); $this->section = 'statuses'; $this->statusModalOpen = true; } public function closeStatusModal(): void { $this->resetValidation(); $this->resetStatusForm(); $this->statusModalOpen = false; } public function resetStatusForm(): void { $this->statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false]; } public function savePriority(): void { $data = $this->validate([ 'priorityForm.name' => ['required', 'string', 'max:80'], 'priorityForm.slug' => ['nullable', 'string', 'max:100'], 'priorityForm.color' => ['required', 'string', Color::validationRule()], 'priorityForm.sort_order' => ['required', 'integer', 'min:0'], 'priorityForm.is_default' => ['boolean'], ])['priorityForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data = $this->guardDefaultColumn('helpdesk_priorities', $data); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(Priority::class, $this->priorityForm['id']); } $this->persistSettingModel(Priority::class, $this->priorityForm['id'], $data); $this->resetPriorityForm(); $this->priorityModalOpen = false; session()->flash('success', 'Priority saved.'); } public function editPriority(int $id): void { $this->priorityForm = Priority::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_default']); $this->priorityForm['color'] = Color::normalise($this->priorityForm['color'] ?? 'gray'); $this->section = 'priorities'; $this->priorityModalOpen = true; } public function deletePriority(int $id): void { Priority::query()->findOrFail($id)->delete(); session()->flash('success', 'Priority deleted.'); } public function openCreatePriorityModal(): void { $this->resetValidation(); $this->resetPriorityForm(); $this->section = 'priorities'; $this->priorityModalOpen = true; } public function closePriorityModal(): void { $this->resetValidation(); $this->resetPriorityForm(); $this->priorityModalOpen = false; } public function resetPriorityForm(): void { $this->priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false]; } public function saveCategory(): void { $this->normalizeNullableFormValues('categoryForm', []); $data = $this->validate([ 'categoryForm.name' => ['required', 'string', 'max:120'], 'categoryForm.slug' => ['nullable', 'string', 'max:140'], 'categoryForm.color' => ['required', 'string', Color::validationRule()], 'categoryForm.description' => ['nullable', 'string'], 'categoryForm.is_default' => ['boolean'], ])['categoryForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data = $this->guardDefaultColumn('helpdesk_categories', $data); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(Category::class, $this->categoryForm['id']); } $this->persistSettingModel(Category::class, $this->categoryForm['id'], $data); $this->resetCategoryForm(); $this->categoryModalOpen = false; session()->flash('success', 'Category saved.'); } public function editCategory(int $id): void { $this->categoryForm = Category::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'description', 'is_default']); $this->categoryForm['color'] = Color::normalise($this->categoryForm['color'] ?? 'blue'); $this->section = 'categories'; $this->categoryModalOpen = true; } public function deleteCategory(int $id): void { Category::query()->findOrFail($id)->delete(); session()->flash('success', 'Category deleted.'); } public function openCreateCategoryModal(): void { $this->resetValidation(); $this->resetCategoryForm(); $this->section = 'categories'; $this->categoryModalOpen = true; } public function closeCategoryModal(): void { $this->resetValidation(); $this->resetCategoryForm(); $this->categoryModalOpen = false; } public function resetCategoryForm(): void { $this->categoryForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false]; } public function saveTeam(): void { $this->resetValidation(); $this->normalizeNullableFormValues('teamForm', []); $data = $this->validate([ 'teamForm.name' => ['required', 'string', 'max:120'], 'teamForm.slug' => ['nullable', 'string', 'max:140'], 'teamForm.color' => ['required', 'string', Color::validationRule()], 'teamForm.description' => ['nullable', 'string'], 'teamForm.is_default' => ['boolean'], 'teamForm.user_ids' => ['array'], 'teamForm.user_ids.*' => ['integer', 'distinct', 'exists:users,id'], ])['teamForm']; $teamId = filled($this->teamForm['id'] ?? null) ? (int) $this->teamForm['id'] : null; $userIds = collect($data['user_ids'] ?? []) ->map(static fn ($id): int => (int) $id) ->unique() ->values() ->all(); unset($data['user_ids']); $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data = $this->guardDefaultColumn('helpdesk_teams', $data); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(Team::class, $teamId); } try { if ($teamId !== null) { /** @var Team $team */ $team = Team::withTrashed()->findOrFail($teamId); if ($team->trashed()) { $team->restore(); } $team->fill($data); $team->save(); } else { // Team slugs are unique even after soft deletion. Recreating a // previously deleted team should restore that record instead // of failing with a duplicate-key exception. /** @var Team|null $team */ $team = Team::withTrashed() ->where('slug', $data['slug']) ->first(); if ($team !== null) { if (! $team->trashed()) { throw ValidationException::withMessages([ 'teamForm.slug' => 'A team with this slug already exists.', ]); } $team->restore(); $team->fill($data); $team->save(); } else { $team = Team::query()->create($data); } } if (Schema::hasTable('helpdesk_team_user')) { $team->users()->sync($userIds); } } catch (ValidationException $exception) { throw $exception; } catch (QueryException $exception) { throw ValidationException::withMessages([ 'teamForm.slug' => 'The team could not be saved. Check that the name/slug is unique and try again.', ]); } $this->resetTeamForm(); $this->teamModalOpen = false; $this->section = 'teams'; session()->flash('success', 'Team saved successfully.'); } public function editTeam(int $id): void { $team = Team::withTrashed()->with('users:id')->findOrFail($id); $this->teamForm = $team->only(['id', 'name', 'slug', 'color', 'description', 'is_default', 'archived_at']); $this->teamForm['user_ids'] = $team->users->pluck('id')->map(static fn ($id): int => (int) $id)->all(); $this->teamForm['color'] = Color::normalise($this->teamForm['color'] ?? 'indigo'); $this->section = 'teams'; $this->teamModalOpen = true; } public function openCreateTeamModal(): void { $this->resetValidation(); $this->resetTeamForm(); $this->section = 'teams'; $this->teamModalOpen = true; } public function closeTeamModal(): void { $this->resetValidation(); $this->resetTeamForm(); $this->teamModalOpen = false; } public function deleteTeam(int $id): void { Team::query()->findOrFail($id)->delete(); session()->flash('success', 'Team deleted.'); } public function removeUserFromTeam(int $teamId, int $userId): void { $team = Team::query()->findOrFail($teamId); $team->users()->detach($userId); if ((int) ($this->teamForm['id'] ?? 0) === $teamId) { $this->teamForm['user_ids'] = array_values(array_filter( array_map('intval', $this->teamForm['user_ids'] ?? []), static fn (int $id): bool => $id !== $userId, )); } session()->flash('success', 'User removed from team.'); } public function resetTeamForm(): void { $this->teamForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null, 'user_ids' => []]; } public function saveProject(): void { $this->normalizeNullableFormValues('projectForm', ['team_id']); $data = $this->validate([ 'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], 'projectForm.title' => ['required', 'string', 'max:160'], 'projectForm.slug' => ['nullable', 'string', 'max:180'], 'projectForm.color' => ['required', 'string', Color::validationRule()], 'projectForm.description' => ['nullable', 'string'], 'projectForm.is_default' => ['boolean'], ])['projectForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['title']); $data = $this->guardDefaultColumn('helpdesk_projects', $data); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(Project::class, $this->projectForm['id']); } $this->persistSettingModel(Project::class, $this->projectForm['id'], $data); $this->resetProjectForm(); $this->projectModalOpen = false; session()->flash('success', 'Project saved.'); } public function editProject(int $id): void { $this->projectForm = Project::query()->findOrFail($id)->only(['id', 'team_id', 'title', 'slug', 'color', 'description', 'is_default', 'archived_at']); $this->projectForm['color'] = Color::normalise($this->projectForm['color'] ?? 'blue'); $this->section = 'projects'; $this->projectModalOpen = true; } public function deleteProject(int $id): void { Project::query()->findOrFail($id)->delete(); session()->flash('success', 'Project deleted.'); } public function openCreateProjectModal(): void { $this->resetValidation(); $this->resetProjectForm(); $this->section = 'projects'; $this->projectModalOpen = true; } public function closeProjectModal(): void { $this->resetValidation(); $this->resetProjectForm(); $this->projectModalOpen = false; } public function resetProjectForm(): void { $this->projectForm = ['id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null]; } public function saveBoard(): void { $this->normalizeNullableFormValues('boardForm', ['owner_id']); $data = $this->validate([ 'boardForm.name' => ['required', 'string', 'max:160'], 'boardForm.slug' => ['nullable', 'string', 'max:180'], 'boardForm.description' => ['nullable', 'string'], 'boardForm.owner_id' => ['nullable', 'integer', 'exists:users,id'], 'boardForm.is_default' => ['boolean'], 'boardForm.sort_order' => ['required', 'integer', 'min:0'], 'boardForm.status_ids' => ['array'], 'boardForm.status_ids.*' => ['integer', 'exists:helpdesk_statuses,id'], ])['boardForm']; $statusIds = collect($data['status_ids'] ?? [])->map(static fn ($id): int => (int) $id)->filter()->values(); unset($data['status_ids']); $data['owner_id'] = blank($data['owner_id'] ?? null) ? null : (int) $data['owner_id']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(KanbanBoard::class, $this->boardForm['id']); } $board = $this->persistSettingModel(KanbanBoard::class, $this->boardForm['id'], $data); $sync = []; foreach ($statusIds as $index => $statusId) { $sync[$statusId] = ['sort_order' => ($index + 1) * 10]; } $board->statuses()->sync($sync); $this->resetBoardForm(); $this->boardModalOpen = false; $this->section = 'boards'; session()->flash('success', 'Kanban board saved.'); } public function editBoard(int $id): void { $board = KanbanBoard::query()->with('statuses')->findOrFail($id); $this->boardForm = [ 'id' => $board->id, 'name' => $board->name, 'slug' => $board->slug, 'description' => $board->description, 'owner_id' => $board->owner_id, 'is_default' => $board->is_default, 'sort_order' => $board->sort_order, 'status_ids' => $board->statuses->pluck('id')->map(static fn ($id): int => (int) $id)->all(), ]; $this->section = 'boards'; $this->boardModalOpen = true; } public function deleteBoard(int $id): void { KanbanBoard::query()->findOrFail($id)->delete(); session()->flash('success', 'Kanban board deleted.'); } public function openCreateBoardModal(): void { $this->resetValidation(); $this->resetBoardForm(); $this->section = 'boards'; $this->boardModalOpen = true; } public function closeBoardModal(): void { $this->resetValidation(); $this->resetBoardForm(); $this->boardModalOpen = false; } public function resetBoardForm(): void { $this->boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []]; } public function saveRepository(): void { $this->normalizeNullableFormValues('repositoryForm', ['project_id', 'webhook_secret']); $data = $this->validate([ 'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], 'repositoryForm.name' => ['required', 'string', 'max:160'], 'repositoryForm.provider' => ['required', 'string', 'max:40'], 'repositoryForm.remote_url' => ['required', 'string', 'max:500'], 'repositoryForm.default_branch' => ['required', 'string', 'max:120'], 'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'], ])['repositoryForm']; $data['provider'] = strtolower(trim((string) $data['provider'])); $this->persistSettingModel(GitRepository::class, $this->repositoryForm['id'], $data); $this->resetRepositoryForm(); $this->repositoryModalOpen = false; session()->flash('success', 'Git repository saved.'); } public function editRepository(int $id): void { $this->repositoryForm = GitRepository::query()->findOrFail($id)->only(['id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret']); $this->section = 'git'; $this->repositoryModalOpen = true; } public function deleteRepository(int $id): void { GitRepository::query()->findOrFail($id)->delete(); session()->flash('success', 'Git repository deleted.'); } public function openCreateRepositoryModal(): void { $this->resetValidation(); $this->resetRepositoryForm(); $this->section = 'git'; $this->repositoryModalOpen = true; } public function closeRepositoryModal(): void { $this->resetValidation(); $this->resetRepositoryForm(); $this->repositoryModalOpen = false; } public function resetRepositoryForm(): void { $this->repositoryForm = ['id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => '']; } public function saveWorkflowTemplate(): void { $this->normalizeNullableFormValues('workflowForm', ['text_body', 'cc', 'bcc']); $data = $this->validate([ 'workflowForm.name' => ['required', 'string', 'max:160'], 'workflowForm.event' => ['required', 'string', 'max:80'], 'workflowForm.subject' => ['required', 'string', 'max:220'], 'workflowForm.html_body' => ['required', 'string'], 'workflowForm.text_body' => ['nullable', 'string'], 'workflowForm.to_role' => ['required', 'string', 'max:40'], 'workflowForm.cc' => ['nullable', 'string', 'max:500'], 'workflowForm.bcc' => ['nullable', 'string', 'max:500'], 'workflowForm.is_active' => ['boolean'], ])['workflowForm']; $this->persistSettingModel(WorkflowTemplate::class, $this->workflowForm['id'], $data); $this->resetWorkflowTemplateForm(); $this->workflowModalOpen = false; session()->flash('success', 'Workflow message saved.'); } public function editWorkflowTemplate(int $id): void { $this->workflowForm = WorkflowTemplate::query()->findOrFail($id)->only(['id', 'name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']); $this->section = 'workflow'; $this->workflowModalOpen = true; } public function deleteWorkflowTemplate(int $id): void { WorkflowTemplate::query()->findOrFail($id)->delete(); session()->flash('success', 'Workflow message deleted.'); } public function openCreateWorkflowTemplateModal(): void { $this->resetValidation(); $this->resetWorkflowTemplateForm(); $this->section = 'workflow'; $this->workflowModalOpen = true; } public function closeWorkflowTemplateModal(): void { $this->resetValidation(); $this->resetWorkflowTemplateForm(); $this->workflowModalOpen = false; } public function resetWorkflowTemplateForm(): void { $this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true]; } public function saveCannedResponse(): void { $this->normalizeNullableFormValues('cannedForm', ['category_id', 'team_id', 'subject']); $data = $this->validate([ 'cannedForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], 'cannedForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], 'cannedForm.name' => ['required', 'string', 'max:160'], 'cannedForm.slug' => ['nullable', 'string', 'max:180'], 'cannedForm.visibility' => ['required', 'string', 'max:40'], 'cannedForm.subject' => ['nullable', 'string', 'max:220'], 'cannedForm.body' => ['required', 'string'], 'cannedForm.is_active' => ['boolean'], 'cannedForm.sort_order' => ['required', 'integer', 'min:0'], ])['cannedForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $this->persistSettingModel(CannedResponse::class, $this->cannedForm['id'], $data); $this->resetCannedResponseForm(); $this->cannedModalOpen = false; session()->flash('success', 'Canned response saved.'); } public function editCannedResponse(int $id): void { $this->cannedForm = CannedResponse::query()->findOrFail($id)->only(['id', 'category_id', 'team_id', 'name', 'slug', 'visibility', 'subject', 'body', 'is_active', 'sort_order']); $this->section = 'canned'; $this->cannedModalOpen = true; } public function deleteCannedResponse(int $id): void { CannedResponse::query()->findOrFail($id)->delete(); session()->flash('success', 'Canned response deleted.'); } public function openCreateCannedResponseModal(): void { $this->resetValidation(); $this->resetCannedResponseForm(); $this->section = 'canned'; $this->cannedModalOpen = true; } public function closeCannedResponseModal(): void { $this->resetValidation(); $this->resetCannedResponseForm(); $this->cannedModalOpen = false; } public function resetCannedResponseForm(): void { $this->cannedForm = ['id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0]; } public function saveSlaPolicy(): void { $this->normalizeNullableFormValues('slaForm', ['priority_id', 'category_id']); $data = $this->validate([ 'slaForm.priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'], 'slaForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], 'slaForm.name' => ['required', 'string', 'max:160'], 'slaForm.slug' => ['nullable', 'string', 'max:180'], 'slaForm.first_response_minutes' => ['required', 'integer', 'min:1'], 'slaForm.resolution_minutes' => ['required', 'integer', 'min:1'], 'slaForm.business_hours_only' => ['boolean'], 'slaForm.is_default' => ['boolean'], 'slaForm.is_active' => ['boolean'], ])['slaForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); if ((bool) ($data['is_default'] ?? false)) { $this->clearOtherDefaults(SlaPolicy::class, $this->slaForm['id']); } $this->persistSettingModel(SlaPolicy::class, $this->slaForm['id'], $data); $this->resetSlaPolicyForm(); $this->slaModalOpen = false; session()->flash('success', 'SLA policy saved.'); } public function editSlaPolicy(int $id): void { $this->slaForm = SlaPolicy::query()->findOrFail($id)->only(['id', 'priority_id', 'category_id', 'name', 'slug', 'first_response_minutes', 'resolution_minutes', 'business_hours_only', 'is_default', 'is_active']); $this->section = 'sla'; $this->slaModalOpen = true; } public function deleteSlaPolicy(int $id): void { SlaPolicy::query()->findOrFail($id)->delete(); session()->flash('success', 'SLA policy deleted.'); } public function openCreateSlaPolicyModal(): void { $this->resetValidation(); $this->resetSlaPolicyForm(); $this->section = 'sla'; $this->slaModalOpen = true; } public function closeSlaPolicyModal(): void { $this->resetValidation(); $this->resetSlaPolicyForm(); $this->slaModalOpen = false; } public function resetSlaPolicyForm(): void { $this->slaForm = ['id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true]; } public function saveAutomationRule(): void { $this->normalizeNullableFormValues('automationForm', ['match_sender', 'match_subject', 'match_mailbox', 'match_category_id', 'set_priority_id', 'set_category_id', 'set_team_id', 'set_project_id', 'set_status_id', 'set_agent_id']); $data = $this->validate([ 'automationForm.name' => ['required', 'string', 'max:160'], 'automationForm.slug' => ['nullable', 'string', 'max:180'], 'automationForm.match_sender' => ['nullable', 'string', 'max:190'], 'automationForm.match_subject' => ['nullable', 'string', 'max:220'], 'automationForm.match_mailbox' => ['nullable', 'string', 'max:190'], 'automationForm.match_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], 'automationForm.set_priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'], 'automationForm.set_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], 'automationForm.set_team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], 'automationForm.set_project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], 'automationForm.set_status_id' => ['nullable', 'integer', 'exists:helpdesk_statuses,id'], 'automationForm.set_agent_id' => ['nullable', 'integer', 'exists:users,id'], 'automationForm.is_active' => ['boolean'], 'automationForm.sort_order' => ['required', 'integer', 'min:0'], ])['automationForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $this->persistSettingModel(AutomationRule::class, $this->automationForm['id'], $data); $this->resetAutomationRuleForm(); $this->automationModalOpen = false; session()->flash('success', 'Automation rule saved.'); } public function editAutomationRule(int $id): void { $this->automationForm = AutomationRule::query()->findOrFail($id)->only(['id', 'name', 'slug', 'match_sender', 'match_subject', 'match_mailbox', 'match_category_id', 'set_priority_id', 'set_category_id', 'set_team_id', 'set_project_id', 'set_status_id', 'set_agent_id', 'is_active', 'sort_order']); $this->section = 'automation'; $this->automationModalOpen = true; } public function deleteAutomationRule(int $id): void { AutomationRule::query()->findOrFail($id)->delete(); session()->flash('success', 'Automation rule deleted.'); } public function openCreateAutomationRuleModal(): void { $this->resetValidation(); $this->resetAutomationRuleForm(); $this->section = 'automation'; $this->automationModalOpen = true; } public function closeAutomationRuleModal(): void { $this->resetValidation(); $this->resetAutomationRuleForm(); $this->automationModalOpen = false; } public function resetAutomationRuleForm(): void { $this->automationForm = ['id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0]; } public function saveProduct(): void { $this->normalizeNullableFormValues('productForm', ['version', 'release_channel', 'description']); $data = $this->validate([ 'productForm.name' => ['required', 'string', 'max:160'], 'productForm.slug' => ['nullable', 'string', 'max:180'], 'productForm.type' => ['required', 'string', 'max:60'], 'productForm.version' => ['nullable', 'string', 'max:80'], 'productForm.release_channel' => ['nullable', 'string', 'max:80'], 'productForm.description' => ['nullable', 'string'], 'productForm.is_active' => ['boolean'], ])['productForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $this->persistSettingModel(Product::class, $this->productForm['id'], $data); $this->resetProductForm(); $this->productModalOpen = false; session()->flash('success', 'Product/service saved.'); } public function editProduct(int $id): void { $this->productForm = Product::query()->findOrFail($id)->only(['id', 'name', 'slug', 'type', 'version', 'release_channel', 'description', 'is_active']); $this->section = 'products'; $this->productModalOpen = true; } public function deleteProduct(int $id): void { Product::query()->findOrFail($id)->delete(); session()->flash('success', 'Product/service deleted.'); } public function openCreateProductModal(): void { $this->resetValidation(); $this->resetProductForm(); $this->section = 'products'; $this->productModalOpen = true; } public function closeProductModal(): void { $this->resetValidation(); $this->resetProductForm(); $this->productModalOpen = false; } public function resetProductForm(): void { $this->productForm = ['id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true]; } public function saveTag(): void { $this->normalizeNullableFormValues('tagForm', []); $data = $this->validate([ 'tagForm.name' => ['required', 'string', 'max:80'], 'tagForm.slug' => ['nullable', 'string', 'max:100'], 'tagForm.color' => ['required', 'string', Color::validationRule()], ])['tagForm']; $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $this->persistSettingModel(Tag::class, $this->tagForm['id'], $data); $this->resetTagForm(); $this->tagModalOpen = false; session()->flash('success', 'Tag saved.'); } public function editTag(int $id): void { $this->tagForm = Tag::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color']); $this->tagForm['color'] = Color::normalise($this->tagForm['color'] ?? 'gray'); $this->section = 'tags'; $this->tagModalOpen = true; } public function deleteTag(int $id): void { Tag::query()->findOrFail($id)->delete(); session()->flash('success', 'Tag deleted.'); } public function openCreateTagModal(): void { $this->resetValidation(); $this->resetTagForm(); $this->section = 'tags'; $this->tagModalOpen = true; } public function closeTagModal(): void { $this->resetValidation(); $this->resetTagForm(); $this->tagModalOpen = false; } public function resetTagForm(): void { $this->tagForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray']; } public function saveChecklistTemplate(): void { $this->normalizeNullableFormValues('checklistTemplateForm', ['category_id', 'project_id']); $data = $this->validate([ 'checklistTemplateForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], 'checklistTemplateForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], 'checklistTemplateForm.name' => ['required', 'string', 'max:160'], 'checklistTemplateForm.slug' => ['nullable', 'string', 'max:180'], 'checklistTemplateForm.items_text' => ['required', 'string'], 'checklistTemplateForm.is_active' => ['boolean'], ])['checklistTemplateForm']; $itemsText = (string) ($data['items_text'] ?? ''); unset($data['items_text']); $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data['items'] = collect(preg_split('/\r\n|\r|\n/', $itemsText) ?: [])->map(fn ($line): string => trim($line))->filter()->values()->all(); $this->persistSettingModel(ChecklistTemplate::class, $this->checklistTemplateForm['id'], $data); $this->resetChecklistTemplateForm(); $this->checklistModalOpen = false; session()->flash('success', 'Checklist template saved.'); } public function editChecklistTemplate(int $id): void { $template = ChecklistTemplate::query()->findOrFail($id); $this->checklistTemplateForm = $template->only(['id', 'category_id', 'project_id', 'name', 'slug', 'is_active']) + [ 'items_text' => collect($template->items ?? [])->map(fn ($item): string => is_array($item) ? (string) ($item['title'] ?? '') : (string) $item)->implode("\n"), ]; $this->section = 'checklists'; $this->checklistModalOpen = true; } public function deleteChecklistTemplate(int $id): void { ChecklistTemplate::query()->findOrFail($id)->delete(); session()->flash('success', 'Checklist template deleted.'); } public function openCreateChecklistTemplateModal(): void { $this->resetValidation(); $this->resetChecklistTemplateForm(); $this->section = 'checklists'; $this->checklistModalOpen = true; } public function closeChecklistTemplateModal(): void { $this->resetValidation(); $this->resetChecklistTemplateForm(); $this->checklistModalOpen = false; } public function resetChecklistTemplateForm(): void { $this->checklistTemplateForm = ['id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true]; } #[Layout('layouts.app')] public function render(): View { return view()->file( resource_path('themes/backend/views/modules/helpdesk/livewire/settings-manager.blade.php'), [ 'statuses' => $this->orderedStatuses(), 'priorities' => $this->orderedPriorities(), 'categories' => $this->orderedCategories(), 'teams' => $this->orderedTeams(), 'teamUsers' => User::query() ->orderBy('name') ->get(['id', 'name', 'email', 'helpdesk_agent', 'helpdesk_admin']), 'projects' => $this->orderedProjects(), 'boardOwners' => User::query()->where(static function ($query): void { $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); })->orderBy('name')->get(['id', 'name', 'email']), 'boards' => KanbanBoard::query() ->with(['statuses', 'owner:id,name,email']) ->withCount('tickets') ->orderBy('sort_order') ->orderBy('name') ->paginate(10, ['*'], 'boardsPage'), 'repositories' => GitRepository::query() ->with(['project']) ->withCount(['commits', 'webhookLogs']) ->orderBy('name') ->paginate(10, ['*'], 'gitPage'), 'workflowTemplates' => WorkflowTemplate::query() ->orderBy('event') ->orderBy('name') ->paginate(10, ['*'], 'workflowPage'), 'cannedResponses' => CannedResponse::query() ->with(['category', 'team']) ->orderBy('sort_order') ->orderBy('name') ->paginate(10, ['*'], 'cannedPage'), 'slaPolicies' => SlaPolicy::query() ->with(['priority', 'category']) ->orderByDesc('is_default') ->orderBy('name') ->paginate(10, ['*'], 'slaPage'), 'automationRules' => AutomationRule::query() ->orderBy('sort_order') ->orderBy('name') ->paginate(10, ['*'], 'rulesPage'), 'products' => Product::query() ->withCount('tickets') ->orderBy('name') ->paginate(10, ['*'], 'productsPage'), 'tags' => Tag::query() ->orderBy('name') ->paginate(10, ['*'], 'tagsPage'), 'checklistTemplates' => ChecklistTemplate::query() ->orderBy('name') ->paginate(10, ['*'], 'checksPage'), 'colourOptions' => Color::options(), ] ); } private function orderedStatuses(): mixed { return Status::query() ->withCount('tickets') ->when($this->hasDefaultColumn('helpdesk_statuses'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('sort_order') ->orderBy('name') ->paginate(10, ['*'], 'statusesPage'); } private function orderedPriorities(): mixed { return Priority::query() ->withCount('tickets') ->when($this->hasDefaultColumn('helpdesk_priorities'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('sort_order') ->orderBy('name') ->paginate(10, ['*'], 'prioritiesPage'); } private function orderedCategories(): mixed { return Category::query() ->withCount('tickets') ->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('name') ->paginate(10, ['*'], 'categoriesPage'); } private function orderedTeams(): mixed { return Team::query() ->with(['users:id,name,email']) ->withCount(['projects', 'tickets', 'users']) ->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('name') ->paginate(10, ['*'], 'teamsPage'); } private function orderedProjects(): mixed { return Project::query() ->with(['team']) ->withCount('tickets') ->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default')) ->orderBy('title') ->paginate(10, ['*'], 'projectsPage'); } /** * Persist a settings record without inserting a null primary key. * * Eloquent updateOrCreate(['id' => null], ...) merges the null id into the * create payload. PostgreSQL rejects that explicit null for bigserial ids, * which made every new Helpdesk settings CRUD form appear to do nothing. * * @template TModel of \Illuminate\Database\Eloquent\Model * * @param class-string $modelClass * @param array $data * @return TModel */ private function persistSettingModel(string $modelClass, int|string|null $id, array $data): object { unset($data['id']); try { if (filled($id)) { /** @var TModel $model */ $model = $modelClass::query()->findOrFail((int) $id); $model->fill($data); $model->save(); return $model; } /** @var TModel $model */ $model = $modelClass::query()->create($data); return $model; } catch (QueryException $exception) { throw ValidationException::withMessages([ 'settings' => 'The record could not be saved. Check the slug is unique for this section and try again.', ]); } } /** * Clear the default flag on every other record without generating invalid null-id SQL. * * @param class-string $modelClass */ private function clearOtherDefaults(string $modelClass, int|string|null $currentId): void { $query = $modelClass::query(); if (filled($currentId)) { $query->whereKeyNot((int) $currentId); } $query->update(['is_default' => false]); } /** * Convert browser select empty strings into null before Livewire validation. * * Flux/select controls submit optional values as empty strings. Laravel's * nullable integer and exists rules expect a real null, so normalising here * keeps every settings CRUD form usable from the browser. * * @param array $keys */ private function normalizeNullableFormValues(string $formProperty, array $keys): void { foreach ($keys as $key) { if (! array_key_exists($key, $this->{$formProperty})) { continue; } if (blank($this->{$formProperty}[$key])) { $this->{$formProperty}[$key] = null; } } } private function normalizeNullableScalar(string $property): void { if (blank($this->{$property})) { $this->{$property} = null; } } /** * Remove is_default before save when the database has not been migrated yet. * * @param array $data * @return array */ private function guardDefaultColumn(string $table, array $data): array { if (! $this->hasDefaultColumn($table)) { unset($data['is_default']); } return $data; } private function hasDefaultColumn(string $table): bool { return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default'); } private function slug(string $value): string { return Str::slug($value); } }