Files
Helpddesk/Modules/Helpdesk/Livewire/SettingsManager.php
T

910 lines
41 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
use Modules\Helpdesk\Models\AutomationRule;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\ChecklistTemplate;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\SlaPolicy;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Tag;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\WorkflowTemplate;
use Modules\Helpdesk\Support\Color;
final class SettingsManager extends Component
{
public string $section = 'statuses';
/** @var array<string, mixed> */
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
/** @var array<string, mixed> */
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false];
/** @var array<string, mixed> */
public array $categoryForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
/** @var array<string, mixed> */
public array $teamForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null, 'user_ids' => []];
/** @var array<string, mixed> */
public array $projectForm = ['id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
/** @var array<string, mixed> */
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
/** @var array<string, mixed> */
public array $repositoryForm = ['id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
/** @var array<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
public array $productForm = ['id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
/** @var array<string, mixed> */
public array $tagForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray'];
/** @var array<string, mixed> */
public array $checklistTemplateForm = ['id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
/** @var array<string, mixed> */
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();
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';
}
public function deleteStatus(int $id): void
{
Status::query()->findOrFail($id)->delete();
session()->flash('success', 'Status deleted.');
}
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();
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';
}
public function deletePriority(int $id): void
{
Priority::query()->findOrFail($id)->delete();
session()->flash('success', 'Priority deleted.');
}
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();
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';
}
public function deleteCategory(int $id): void
{
Category::query()->findOrFail($id)->delete();
session()->flash('success', 'Category deleted.');
}
public function resetCategoryForm(): void
{
$this->categoryForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
}
public function saveTeam(): void
{
$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'];
$userIds = array_values(array_unique(array_map('intval', $data['user_ids'] ?? [])));
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, $this->teamForm['id']);
}
/** @var Team $team */
$team = $this->persistSettingModel(Team::class, $this->teamForm['id'], $data);
$team->users()->sync($userIds);
$this->resetTeamForm();
session()->flash('success', 'Team saved.');
}
public function editTeam(int $id): void
{
$team = Team::query()->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';
}
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();
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';
}
public function deleteProject(int $id): void
{
Project::query()->findOrFail($id)->delete();
session()->flash('success', 'Project deleted.');
}
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->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';
}
public function deleteBoard(int $id): void
{
KanbanBoard::query()->findOrFail($id)->delete();
session()->flash('success', 'Kanban board deleted.');
}
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();
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';
}
public function deleteRepository(int $id): void
{
GitRepository::query()->findOrFail($id)->delete();
session()->flash('success', 'Git repository deleted.');
}
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();
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';
}
public function deleteWorkflowTemplate(int $id): void
{
WorkflowTemplate::query()->findOrFail($id)->delete();
session()->flash('success', 'Workflow message deleted.');
}
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();
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';
}
public function deleteCannedResponse(int $id): void
{
CannedResponse::query()->findOrFail($id)->delete();
session()->flash('success', 'Canned response deleted.');
}
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();
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';
}
public function deleteSlaPolicy(int $id): void
{
SlaPolicy::query()->findOrFail($id)->delete();
session()->flash('success', 'SLA policy deleted.');
}
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();
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';
}
public function deleteAutomationRule(int $id): void
{
AutomationRule::query()->findOrFail($id)->delete();
session()->flash('success', 'Automation rule deleted.');
}
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();
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';
}
public function deleteProduct(int $id): void
{
Product::query()->findOrFail($id)->delete();
session()->flash('success', 'Product/service deleted.');
}
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();
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';
}
public function deleteTag(int $id): void
{
Tag::query()->findOrFail($id)->delete();
session()->flash('success', 'Tag deleted.');
}
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();
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';
}
public function deleteChecklistTemplate(int $id): void
{
ChecklistTemplate::query()->findOrFail($id)->delete();
session()->flash('success', 'Checklist template deleted.');
}
public function resetChecklistTemplateForm(): void
{
$this->checklistTemplateForm = ['id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
}
public function render(): View
{
return view('modules.helpdesk.livewire.settings-manager', [
'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')->get(),
'repositories' => GitRepository::query()->with(['project'])->withCount(['commits', 'webhookLogs'])->orderBy('name')->get(),
'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(),
'cannedResponses' => CannedResponse::query()->with(['category', 'team'])->orderBy('sort_order')->orderBy('name')->get(),
'slaPolicies' => SlaPolicy::query()->with(['priority', 'category'])->orderByDesc('is_default')->orderBy('name')->get(),
'automationRules' => AutomationRule::query()->orderBy('sort_order')->orderBy('name')->get(),
'products' => Product::query()->withCount('tickets')->orderBy('name')->get(),
'tags' => Tag::query()->orderBy('name')->get(),
'checklistTemplates' => ChecklistTemplate::query()->orderBy('name')->get(),
'colourOptions' => Color::options(),
])->layout('layouts.app');
}
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')
->get();
}
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')
->get();
}
private function orderedCategories(): mixed
{
return Category::query()
->withCount('tickets')
->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('name')
->get();
}
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')
->get();
}
private function orderedProjects(): mixed
{
return Project::query()
->with(['team'])
->withCount('tickets')
->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('title')
->get();
}
/**
* 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<TModel> $modelClass
* @param array<string, mixed> $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<Model> $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<int, string> $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<string, mixed> $data
* @return array<string, mixed>
*/
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);
}
}