This commit is contained in:
Mark Gregory
2026-06-15 17:55:36 +01:00
parent 578a977c59
commit 0a9746fa77
25 changed files with 844 additions and 106 deletions
@@ -12,6 +12,7 @@ return new class extends Migration
public function up(): void
{
foreach ([
'helpdesk_statuses',
'helpdesk_priorities',
'helpdesk_categories',
'helpdesk_projects',
@@ -19,9 +20,7 @@ return new class extends Migration
] as $table) {
if (! Schema::hasColumn($table, 'is_default')) {
Schema::table($table, function (Blueprint $table): void {
$table->boolean('is_default')
->default(false)
->after('is_active');
$table->boolean('is_default')->default(false);
});
}
}
@@ -33,6 +32,7 @@ return new class extends Migration
public function down(): void
{
foreach ([
'helpdesk_statuses',
'helpdesk_priorities',
'helpdesk_categories',
'helpdesk_projects',
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
foreach ([
'helpdesk_statuses',
'helpdesk_priorities',
'helpdesk_categories',
'helpdesk_projects',
'helpdesk_teams',
] as $tableName) {
if (Schema::hasTable($tableName) && ! Schema::hasColumn($tableName, 'is_default')) {
Schema::table($tableName, static function (Blueprint $table): void {
$table->boolean('is_default')->default(false);
});
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
foreach ([
'helpdesk_statuses',
'helpdesk_priorities',
'helpdesk_categories',
'helpdesk_projects',
'helpdesk_teams',
] as $tableName) {
if (Schema::hasTable($tableName) && Schema::hasColumn($tableName, 'is_default')) {
Schema::table($tableName, static function (Blueprint $table): void {
$table->dropColumn('is_default');
});
}
}
}
};
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\Category;
@@ -24,8 +24,6 @@ final class HelpdeskDatabaseSeeder extends Seeder
{
/**
* Seed the Helpdesk module lookup data and default workflow templates.
*
* @return void
*/
public function run(): void
{
@@ -40,18 +38,19 @@ final class HelpdeskDatabaseSeeder extends Seeder
/**
* Seed ticket workflow statuses.
*
* @return void
*/
private function seedStatuses(): void
{
Status::query()->where('is_default', true)->update(['is_default' => false]);
foreach ([
['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false],
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false],
['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false],
['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true],
['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false, 'is_default' => true],
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false, 'is_default' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false, 'is_default' => false],
['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false, 'is_default' => false],
['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false, 'is_default' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true, 'is_default' => false],
] as $row) {
Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
@@ -59,15 +58,15 @@ final class HelpdeskDatabaseSeeder extends Seeder
/**
* Seed ticket priorities.
*
* @return void
*/
private function seedPriorities(): void
{
Priority::query()->where('is_default', true)->update(['is_default' => false]);
foreach ([
['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10],
['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20],
['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30],
['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10, 'is_default' => false],
['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20, 'is_default' => true],
['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30, 'is_default' => false],
] as $row) {
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
@@ -75,16 +74,18 @@ final class HelpdeskDatabaseSeeder extends Seeder
/**
* Seed ticket categories.
*
* @return void
*/
private function seedCategories(): void
{
Category::query()
->whereNull('company_id')
->where('is_default', true)
->update(['is_default' => false]);
foreach ([
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.'],
['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.'],
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.'],
['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.'],
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.', 'is_default' => true],
['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.', 'is_default' => false],
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.', 'is_default' => false],
['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.', 'is_default' => false],
] as $row) {
Category::query()->updateOrCreate(
['company_id' => null, 'slug' => Str::slug($row['name'])],
@@ -95,11 +96,20 @@ final class HelpdeskDatabaseSeeder extends Seeder
/**
* Seed the default support team and a default project.
*
* @return void
*/
private function seedTeamsAndProjects(): void
{
Team::query()
->whereNull('company_id')
->where('is_default', true)
->update(['is_default' => false]);
Project::query()
->whereNull('company_id')
->where('is_default', true)
->update(['is_default' => false]);
$team = Team::query()->updateOrCreate(
['company_id' => null, 'slug' => 'support'],
[
@@ -108,6 +118,7 @@ final class HelpdeskDatabaseSeeder extends Seeder
'slug' => 'support',
'color' => 'blue',
'description' => 'Default support team for triage, ownership and ticket routing.',
'is_default' => true,
]
);
@@ -120,14 +131,13 @@ final class HelpdeskDatabaseSeeder extends Seeder
'slug' => 'support-desk',
'color' => 'indigo',
'description' => 'Default project for operational helpdesk tickets.',
'is_default' => true,
]
);
}
/**
* Seed the default configurable Kanban board.
*
* @return void
*/
private function seedKanbanBoards(): void
{
@@ -160,8 +170,6 @@ final class HelpdeskDatabaseSeeder extends Seeder
/**
* Seed default professional workflow email templates.
*
* @return void
*/
private function seedWorkflowTemplates(): void
{
@@ -235,7 +243,17 @@ final class HelpdeskDatabaseSeeder extends Seeder
['name' => 'Escalated', 'slug' => 'escalated', 'color' => 'red'],
['name' => 'Waiting on customer', 'slug' => 'waiting-on-customer', 'color' => 'amber'],
] as $tag) {
Tag::query()->updateOrCreate(['company_id' => null, 'slug' => $tag['slug']], $tag + ['company_id' => null]);
Tag::query()->updateOrCreate(
['company_id' => null, 'slug' => $tag['slug']],
$tag + ['company_id' => null]
);
}
if (Schema::hasColumn('helpdesk_tags', 'is_default')) {
Tag::query()
->whereNull('company_id')
->where('is_default', true)
->update(['is_default' => false]);
}
Product::query()->updateOrCreate(
@@ -252,6 +270,4 @@ final class HelpdeskDatabaseSeeder extends Seeder
]
);
}
}
@@ -0,0 +1,17 @@
# Helpdesk defaults and tags update
After copying this module update into the application, run:
```bash
php artisan migrate
php artisan optimize:clear
php artisan view:clear
php artisan livewire:discover
php artisan octane:reload
```
If Octane is not running, skip `octane:reload`.
The Helpdesk settings page now includes a Defaults tab for the default company. Statuses, priorities, categories, teams and projects have an `is_default` flag. Ticket creation reads those defaults in `TicketCreate::mount()` and `TicketService` reads the default status before falling back to the configured `new` status slug.
Tickets support tags on create/edit through `tag_ids`, synced to `helpdesk_ticket_tag`.
+186 -27
View File
@@ -28,20 +28,22 @@ final class SettingsManager extends Component
{
public string $section = 'statuses';
/** @var array<string, mixed> */
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
public int|string|null $default_helpdesk_company_id = null;
/** @var array<string, mixed> */
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
/** @var array<string, mixed> */
public array $categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_default' => false];
/** @var array<string, mixed> */
public array $teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
public array $categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
/** @var array<string, mixed> */
public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
public array $teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
/** @var array<string, mixed> */
public array $projectForm = ['id' => null, 'company_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' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
@@ -72,11 +74,12 @@ final class SettingsManager extends Component
public function mount(): void
{
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
$this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
$this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
$this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
$this->loadDefaultHelpdeskCompany();
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_default' => false];
$this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
$this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
$this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
$this->repositoryForm += ['id' => null, 'company_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];
@@ -96,9 +99,16 @@ final class SettingsManager extends Component
'statusForm.color' => ['required', 'string', 'max:30'],
'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)) {
Status::query()->whereKeyNot($this->statusForm['id'])->update(['is_default' => false]);
}
Status::query()->updateOrCreate(['id' => $this->statusForm['id']], $data);
$this->resetStatusForm();
session()->flash('success', 'Status saved.');
@@ -106,7 +116,7 @@ final class SettingsManager extends Component
public function editStatus(int $id): void
{
$this->statusForm = Status::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_closed']);
$this->statusForm = Status::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_closed', 'is_default']);
$this->section = 'statuses';
}
@@ -118,7 +128,7 @@ final class SettingsManager extends Component
public function resetStatusForm(): void
{
$this->statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
$this->statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
}
public function savePriority(): void
@@ -128,9 +138,15 @@ final class SettingsManager extends Component
'priorityForm.slug' => ['nullable', 'string', 'max:100'],
'priorityForm.color' => ['required', 'string', 'max:30'],
'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)) {
Priority::query()->whereKeyNot($this->priorityForm['id'])->update(['is_default' => false]);
}
Priority::query()->updateOrCreate(['id' => $this->priorityForm['id']], $data);
$this->resetPriorityForm();
session()->flash('success', 'Priority saved.');
@@ -138,7 +154,7 @@ final class SettingsManager extends Component
public function editPriority(int $id): void
{
$this->priorityForm = Priority::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order']);
$this->priorityForm = Priority::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_default']);
$this->section = 'priorities';
}
@@ -150,7 +166,7 @@ final class SettingsManager extends Component
public function resetPriorityForm(): void
{
$this->priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
$this->priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_default' => false];
}
public function saveCategory(): void
@@ -161,9 +177,18 @@ final class SettingsManager extends Component
'categoryForm.slug' => ['nullable', 'string', 'max:140'],
'categoryForm.color' => ['required', 'string', 'max:30'],
'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)) {
Category::query()
->whereKeyNot($this->categoryForm['id'])
->where('company_id', $data['company_id'])
->update(['is_default' => false]);
}
Category::query()->updateOrCreate(['id' => $this->categoryForm['id']], $data);
$this->resetCategoryForm();
session()->flash('success', 'Category saved.');
@@ -171,7 +196,7 @@ final class SettingsManager extends Component
public function editCategory(int $id): void
{
$this->categoryForm = Category::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description']);
$this->categoryForm = Category::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description', 'is_default']);
$this->section = 'categories';
}
@@ -183,7 +208,7 @@ final class SettingsManager extends Component
public function resetCategoryForm(): void
{
$this->categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
$this->categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
}
public function saveTeam(): void
@@ -194,9 +219,18 @@ final class SettingsManager extends Component
'teamForm.slug' => ['nullable', 'string', 'max:140'],
'teamForm.color' => ['required', 'string', 'max:30'],
'teamForm.description' => ['nullable', 'string'],
'teamForm.is_default' => ['boolean'],
])['teamForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
$data = $this->guardDefaultColumn('helpdesk_teams', $data);
if ((bool) ($data['is_default'] ?? false)) {
Team::query()
->whereKeyNot($this->teamForm['id'])
->where('company_id', $data['company_id'])
->update(['is_default' => false]);
}
Team::query()->updateOrCreate(['id' => $this->teamForm['id']], $data);
$this->resetTeamForm();
session()->flash('success', 'Team saved.');
@@ -204,7 +238,7 @@ final class SettingsManager extends Component
public function editTeam(int $id): void
{
$this->teamForm = Team::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description', 'archived_at']);
$this->teamForm = Team::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description', 'is_default', 'archived_at']);
$this->section = 'teams';
}
@@ -216,7 +250,7 @@ final class SettingsManager extends Component
public function resetTeamForm(): void
{
$this->teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
$this->teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
}
public function saveProject(): void
@@ -228,9 +262,18 @@ final class SettingsManager extends Component
'projectForm.slug' => ['nullable', 'string', 'max:180'],
'projectForm.color' => ['required', 'string', 'max:30'],
'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)) {
Project::query()
->whereKeyNot($this->projectForm['id'])
->where('company_id', $data['company_id'])
->update(['is_default' => false]);
}
Project::query()->updateOrCreate(['id' => $this->projectForm['id']], $data);
$this->resetProjectForm();
session()->flash('success', 'Project saved.');
@@ -238,7 +281,7 @@ final class SettingsManager extends Component
public function editProject(int $id): void
{
$this->projectForm = Project::query()->findOrFail($id)->only(['id', 'company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at']);
$this->projectForm = Project::query()->findOrFail($id)->only(['id', 'company_id', 'team_id', 'title', 'slug', 'color', 'description', 'is_default', 'archived_at']);
$this->section = 'projects';
}
@@ -250,7 +293,7 @@ final class SettingsManager extends Component
public function resetProjectForm(): void
{
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
}
@@ -614,15 +657,54 @@ final class SettingsManager extends Component
$this->checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
}
public function saveDefaultHelpdeskCompany(): void
{
if (! Schema::hasTable('companies') || ! Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
session()->flash('error', 'The default Helpdesk company column has not been migrated yet.');
return;
}
$this->validate([
'default_helpdesk_company_id' => ['nullable'],
]);
$companyId = filled($this->default_helpdesk_company_id)
? (int) $this->default_helpdesk_company_id
: null;
DB::table('companies')->update(['is_default_helpdesk_company' => false]);
if ($companyId !== null) {
DB::table('companies')
->where('id', $companyId)
->update(['is_default_helpdesk_company' => true]);
}
session()->flash('success', 'Default Helpdesk company saved.');
}
private function loadDefaultHelpdeskCompany(): void
{
if (! Schema::hasTable('companies') || ! Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
return;
}
$this->default_helpdesk_company_id = DB::table('companies')
->where('is_default_helpdesk_company', true)
->value('id');
}
public function render(): View
{
return view('helpdesk::livewire.settings-manager', [
'companies' => $this->companies(),
'statuses' => Status::query()->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
'priorities' => Priority::query()->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
'categories' => Category::query()->withCount('tickets')->orderBy('name')->get(),
'teams' => Team::query()->withCount(['projects', 'tickets'])->orderBy('name')->get(),
'projects' => Project::query()->with(['team'])->withCount('tickets')->orderBy('title')->get(),
'statuses' => $this->orderedStatuses(),
'priorities' => $this->orderedPriorities(),
'categories' => $this->orderedCategories(),
'teams' => $this->orderedTeams(),
'projects' => $this->orderedProjects(),
'boards' => KanbanBoard::query()->with('statuses')->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(),
@@ -635,6 +717,77 @@ final class SettingsManager extends Component
])->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()
->withCount(['projects', 'tickets'])
->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();
}
/**
* 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');
}
/** @return array<int, object> */
private function companies(): array
{
@@ -642,7 +795,13 @@ final class SettingsManager extends Component
return [];
}
return DB::table('companies')->select(['id', 'name'])->orderBy('name')->get()->all();
$columns = ['id', 'name'];
if (Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
$columns[] = 'is_default_helpdesk_company';
}
return DB::table('companies')->select($columns)->orderBy('name')->get()->all();
}
private function slug(string $value): string
+216 -6
View File
@@ -16,7 +16,9 @@ use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\Tag;
use Modules\Helpdesk\Services\TicketService;
final class TicketCreate extends Component
@@ -32,6 +34,9 @@ final class TicketCreate extends Component
#[Validate('required|string|min:10')]
public string $body = '';
#[Validate('nullable|integer|exists:helpdesk_statuses,id')]
public ?int $status_id = null;
#[Validate('required|integer|exists:helpdesk_priorities,id')]
public ?int $priority_id = null;
@@ -50,10 +55,23 @@ final class TicketCreate extends Component
#[Validate('nullable|date')]
public ?string $due_at = null;
/** @var array<int, int> */
#[Validate('array')]
public array $tag_ids = [];
/** @var array<int, TemporaryUploadedFile> */
#[Validate('array')]
public array $attachments = [];
/**
* Initialise configured Helpdesk defaults for a new ticket.
*/
public function mount(): void
{
$this->applyDefaults();
}
/**
* Store a new helpdesk ticket.
*/
@@ -63,34 +81,226 @@ final class TicketCreate extends Component
$this->validate([
'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
]);
$ticket = $tickets->create(Auth::user(), [
'company_id' => $this->company_id,
'subject' => $this->subject,
'body' => $this->body,
'status_id' => $this->status_id,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
'product_id' => $this->product_id,
'team_id' => $this->team_id,
'due_at' => $this->due_at,
'tag_ids' => $this->tag_ids,
], $this->attachments);
session()->flash('success', 'Ticket '.$ticket->reference.' has been created.');
return redirect()->route('helpdesk.tickets.index');
return $this->redirectRoute('helpdesk.tickets.index', navigate: true);
}
public function render(): View
{
$this->applyDefaults();
return view('helpdesk::livewire.ticket-create', [
'priorities' => Priority::query()->orderBy('sort_order')->get(),
'categories' => Category::query()->orderBy('name')->get(),
'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(),
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(),
'statuses' => $this->statusOptions(),
'priorities' => $this->priorityOptions(),
'categories' => $this->categoryOptions(),
'companies' => $this->companyOptions(),
'teams' => $this->teamOptions(),
'projects' => $this->projectOptions(),
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
'tags' => Tag::query()->orderBy('name')->get(),
])->layout('layouts.app');
}
/**
* Apply default values from Helpdesk/company lookup configuration.
*/
private function applyDefaults(): void
{
if (blank($this->company_id) && Schema::hasTable('companies') && Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
$this->company_id = $this->nullableInt(DB::table('companies')
->where('is_default_helpdesk_company', true)
->orderBy('name')
->value('id'));
}
if (blank($this->status_id) && $this->hasDefaultColumn('helpdesk_statuses')) {
$this->status_id = $this->nullableInt(Status::query()
->where('is_default', true)
->orderBy('sort_order')
->value('id'));
}
if (blank($this->priority_id) && $this->hasDefaultColumn('helpdesk_priorities')) {
$this->priority_id = $this->nullableInt(Priority::query()
->where('is_default', true)
->orderBy('sort_order')
->value('id'));
}
if (blank($this->category_id) && $this->hasDefaultColumn('helpdesk_categories')) {
$this->category_id = $this->nullableInt(Category::query()
->where('is_default', true)
->when($this->company_id, fn ($query) => $query->where(function ($query): void {
$query->where('company_id', $this->company_id)
->orWhereNull('company_id');
}))
->orderByRaw('company_id is null')
->orderBy('name')
->value('id'));
}
if (blank($this->team_id) && $this->hasDefaultColumn('helpdesk_teams')) {
$this->team_id = $this->nullableInt(Team::query()
->where('is_default', true)
->whereNull('archived_at')
->when($this->company_id, fn ($query) => $query->where(function ($query): void {
$query->where('company_id', $this->company_id)
->orWhereNull('company_id');
}))
->orderByRaw('company_id is null')
->orderBy('name')
->value('id'));
}
if (blank($this->project_id) && $this->hasDefaultColumn('helpdesk_projects')) {
$this->project_id = $this->nullableInt(Project::query()
->where('is_default', true)
->whereNull('archived_at')
->when($this->company_id, fn ($query) => $query->where(function ($query): void {
$query->where('company_id', $this->company_id)
->orWhereNull('company_id');
}))
->when($this->team_id, fn ($query) => $query->where(function ($query): void {
$query->where('team_id', $this->team_id)
->orWhereNull('team_id');
}))
->orderByRaw('company_id is null')
->orderByRaw('team_id is null')
->orderBy('title')
->value('id'));
}
}
/**
* Return status options with default records listed first.
*/
private function statusOptions(): mixed
{
return Status::query()
->when($this->hasDefaultColumn('helpdesk_statuses'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('sort_order')
->get();
}
/**
* Return priority options with default records listed first.
*/
private function priorityOptions(): mixed
{
return Priority::query()
->when($this->hasDefaultColumn('helpdesk_priorities'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('sort_order')
->get();
}
/**
* Return category options with default records listed first.
*/
private function categoryOptions(): mixed
{
return Category::query()
->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('name')
->get();
}
/**
* Return team options with default records listed first.
*/
private function teamOptions(): mixed
{
return Team::query()
->whereNull('archived_at')
->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('name')
->get();
}
/**
* Return project options with default records listed first.
*/
private function projectOptions(): mixed
{
return Project::query()
->whereNull('archived_at')
->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default'))
->orderBy('title')
->get();
}
/**
* Return company options with default flag support when available.
*/
private function companyOptions(): mixed
{
if (! Schema::hasTable('companies')) {
return collect();
}
$columns = ['id', 'name'];
if (Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
$columns[] = 'is_default_helpdesk_company';
}
return DB::table('companies')
->select($columns)
->orderByDesc(Schema::hasColumn('companies', 'is_default_helpdesk_company') ? 'is_default_helpdesk_company' : 'id')
->orderBy('name')
->get();
}
/**
* Reload dependent defaults after the company changes.
*/
public function updatedCompanyId(mixed $value): void
{
$this->company_id = $this->nullableInt($value);
$this->category_id = null;
$this->team_id = null;
$this->project_id = null;
$this->applyDefaults();
}
/**
* Convert nullable database / Livewire values to nullable integers.
*/
private function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
return (int) $value;
}
/**
* Check whether a Helpdesk lookup table has the default flag column.
*/
private function hasDefaultColumn(string $table): bool
{
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
}
}
+16 -2
View File
@@ -16,6 +16,7 @@ use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Tag;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\SlaService;
@@ -58,10 +59,15 @@ final class TicketEdit extends Component
#[Validate('nullable|date')]
public ?string $due_at = null;
/** @var array<int, int> */
#[Validate('array')]
public array $tag_ids = [];
public function mount(Ticket $ticket): void
{
$this->authorize('update', $ticket);
$this->ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'agent']) ?? $ticket;
$this->ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'agent', 'tags']) ?? $ticket;
$this->company_id = $this->ticket->company_id;
$this->subject = $this->ticket->subject;
@@ -74,6 +80,7 @@ final class TicketEdit extends Component
$this->team_id = $this->ticket->team_id;
$this->agent_id = $this->ticket->agent_id;
$this->due_at = $this->ticket->due_at?->format('Y-m-d');
$this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all();
}
public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed
@@ -81,6 +88,10 @@ final class TicketEdit extends Component
$this->authorize('update', $this->ticket);
$this->validate();
$this->validate([
'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
]);
$status = Status::query()->findOrFail($this->status_id);
$this->ticket->forceFill([
@@ -99,11 +110,13 @@ final class TicketEdit extends Component
'last_activity_at' => now(),
])->save();
$this->ticket->tags()->sync(array_values(array_unique(array_map('intval', $this->tag_ids))));
if ($status->is_closed) {
$sla->recordClosure($this->ticket);
}
$fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $this->ticket;
$fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags']) ?? $this->ticket;
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
session()->flash('success', 'Ticket ' . $fresh->reference . ' has been updated.');
@@ -121,6 +134,7 @@ final class TicketEdit extends Component
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(),
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
'tags' => Tag::query()->orderBy('name')->get(),
'agents' => User::query()
->where(static function ($query): void {
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
+2 -2
View File
@@ -17,10 +17,10 @@ final class Category extends Model
protected $table = 'helpdesk_categories';
/** @var list<string> */
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description'];
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description', 'is_default'];
/** @var array<string, string> */
protected $casts = ['company_id' => 'integer'];
protected $casts = ['company_id' => 'integer', 'is_default' => 'boolean'];
/** @return HasMany<Ticket> */
public function tickets(): HasMany
+2 -2
View File
@@ -15,10 +15,10 @@ final class Priority extends Model
protected $table = 'helpdesk_priorities';
/** @var list<string> */
protected $fillable = ['name', 'slug', 'color', 'sort_order'];
protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_default'];
/** @var array<string, string> */
protected $casts = ['sort_order' => 'integer'];
protected $casts = ['sort_order' => 'integer', 'is_default' => 'boolean'];
/** @return HasMany<Ticket> */
public function tickets(): HasMany
+2 -2
View File
@@ -18,10 +18,10 @@ final class Project extends Model
protected $table = 'helpdesk_projects';
/** @var list<string> */
protected $fillable = ['company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at'];
protected $fillable = ['company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at', 'is_default'];
/** @var array<string, string> */
protected $casts = ['company_id' => 'integer', 'team_id' => 'integer', 'archived_at' => 'datetime'];
protected $casts = ['company_id' => 'integer', 'team_id' => 'integer', 'archived_at' => 'datetime', 'is_default' => 'boolean'];
/** @return BelongsTo<Team, Project> */
public function team(): BelongsTo
+2 -2
View File
@@ -22,10 +22,10 @@ final class Status extends Model
protected $table = 'helpdesk_statuses';
/** @var list<string> */
protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_closed'];
protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_closed', 'is_default'];
/** @var array<string, string> */
protected $casts = ['is_closed' => 'boolean', 'sort_order' => 'integer'];
protected $casts = ['is_closed' => 'boolean', 'is_default' => 'boolean', 'sort_order' => 'integer'];
/** @return HasMany<Ticket> */
public function tickets(): HasMany
+2 -1
View File
@@ -17,12 +17,13 @@ final class Team extends Model
protected $table = 'helpdesk_teams';
/** @var list<string> */
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description', 'archived_at'];
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description', 'archived_at', 'is_default'];
/** @var array<string, string> */
protected $casts = [
'company_id' => 'integer',
'archived_at' => 'datetime',
'is_default' => 'boolean',
];
/** @return HasMany<Project> */
@@ -1,7 +1,14 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr><th class="px-4 py-3">Name</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Colour</th><th class="px-4 py-3">Tickets</th><th class="px-4 py-3 text-right">Actions</th></tr>
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Slug</th>
<th class="px-4 py-3">Colour</th>
<th class="px-4 py-3">Default</th>
<th class="px-4 py-3">Tickets</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
@@ -9,11 +16,19 @@
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
<td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td>
<td class="px-4 py-3">
<flux:badge color="{{ ($item->is_default ?? false) ? 'emerald' : 'zinc' }}">
{{ ($item->is_default ?? false) ? 'Default' : 'No' }}
</flux:badge>
</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-4 py-3 text-right"><flux:button size="sm" wire:click="{{ $edit }}({{ $item->id }})" variant="primary">Edit</flux:button><flux:button size="sm" wire:click="{{ $delete }}({{ $item->id }})" variant="danger">Delete</flux:button></td>
<td class="px-4 py-3 text-right">
<flux:button size="sm" wire:click="{{ $edit }}({{ $item->id }})" variant="primary">Edit</flux:button>
<flux:button size="sm" wire:click="{{ $delete }}({{ $item->id }})" variant="danger">Delete</flux:button>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-10 text-center text-zinc-500">No records yet.</td></tr>
<tr><td colspan="6" class="px-4 py-10 text-center text-zinc-500">No records yet.</td></tr>
@endforelse
</tbody>
</table>
@@ -1 +1,37 @@
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"><tr><th class="px-4 py-3">Project</th><th class="px-4 py-3">Team</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Colour</th><th class="px-4 py-3">Tickets</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">@forelse ($items as $item)<tr><td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td><td class="px-4 py-3 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td><td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td><td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td><td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count }}</td><td class="px-4 py-3 text-right"><flux:button size="sm" wire:click="editProject({{ $item->id }})" variant="primary">Edit</flux:button><flux:button size="sm" wire:click="deleteProject({{ $item->id }})" variant="danger">Delete</flux:button></td></tr>@empty<tr><td colspan="6" class="px-4 py-10 text-center text-zinc-500">No projects yet.</td></tr>@endforelse</tbody></table></div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-4 py-3">Project</th>
<th class="px-4 py-3">Team</th>
<th class="px-4 py-3">Slug</th>
<th class="px-4 py-3">Colour</th>
<th class="px-4 py-3">Default</th>
<th class="px-4 py-3">Tickets</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td>
<td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
<td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td>
<td class="px-4 py-3">
<flux:badge color="{{ ($item->is_default ?? false) ? 'emerald' : 'zinc' }}">
{{ ($item->is_default ?? false) ? 'Default' : 'No' }}
</flux:badge>
</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count }}</td>
<td class="px-4 py-3 text-right">
<flux:button size="sm" wire:click="editProject({{ $item->id }})" variant="primary">Edit</flux:button>
<flux:button size="sm" wire:click="deleteProject({{ $item->id }})" variant="danger">Delete</flux:button>
</td>
</tr>
@empty
<tr><td colspan="7" class="px-4 py-10 text-center text-zinc-500">No projects yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
@@ -1 +1,37 @@
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"><tr><th class="px-4 py-3">Team</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Colour</th><th class="px-4 py-3">Projects</th><th class="px-4 py-3">Tickets</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">@forelse ($items as $item)<tr><td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td><td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td><td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td><td class="px-4 py-3 text-zinc-500">{{ $item->projects_count }}</td><td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count }}</td><td class="px-4 py-3 text-right"><flux:button size="sm" wire:click="editTeam({{ $item->id }})" variant="primary">Edit</flux:button><flux:button size="sm" wire:click="deleteTeam({{ $item->id }})" variant="danger">Delete</flux:button></td></tr>@empty<tr><td colspan="6" class="px-4 py-10 text-center text-zinc-500">No teams yet.</td></tr>@endforelse</tbody></table></div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-4 py-3">Team</th>
<th class="px-4 py-3">Slug</th>
<th class="px-4 py-3">Colour</th>
<th class="px-4 py-3">Default</th>
<th class="px-4 py-3">Projects</th>
<th class="px-4 py-3">Tickets</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
<td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td>
<td class="px-4 py-3">
<flux:badge color="{{ ($item->is_default ?? false) ? 'emerald' : 'zinc' }}">
{{ ($item->is_default ?? false) ? 'Default' : 'No' }}
</flux:badge>
</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->projects_count }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count }}</td>
<td class="px-4 py-3 text-right">
<flux:button size="sm" wire:click="editTeam({{ $item->id }})" variant="primary">Edit</flux:button>
<flux:button size="sm" wire:click="deleteTeam({{ $item->id }})" variant="danger">Delete</flux:button>
</td>
</tr>
@empty
<tr><td colspan="7" class="px-4 py-10 text-center text-zinc-500">No teams yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
@@ -7,7 +7,7 @@
<h1 class="mt-2 text-3xl font-semibold tracking-tight md:text-4xl">Helpdesk dashboard</h1>
<p class="mt-3 max-w-3xl text-sm leading-6 text-blue-100/90">Track open workload, closed tickets, response performance, project activity, priorities, Git commits and workflow communication from one professional support workspace.</p>
<div class="mt-5 flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus" class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-500">New ticket</flux:button>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost" icon="ticket">Active tickets</flux:button>
<flux:button :href="route('helpdesk.kanban')" wire:navigate variant="ghost" icon="squares-2x2">Kanban</flux:button>
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Settings</flux:button>
@@ -9,7 +9,7 @@
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus" class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-500">New ticket</flux:button>
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Configure boards</flux:button>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost" icon="list-bullet">List view</flux:button>
</div>
@@ -53,6 +53,7 @@
'checklists' => ['label' => 'Checks', 'count' => $checklistTemplates->count()],
'tags' => ['label' => 'Tags', 'count' => $tags->count()],
'products' => ['label' => 'Products', 'count' => $products->count()],
'defaults' => ['label' => 'Defaults', 'count' => 1],
] as $key => $item)
<button type="button" wire:click="$set('section', '{{ $key }}')" class="rounded-2xl border px-4 py-3 text-left shadow-sm transition {{ $section === $key ? 'border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-100' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800' }}">
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
@@ -61,6 +62,33 @@
@endforeach
</div>
@if ($section === 'defaults')
<x-helpdesk::settings-section title="Ticket defaults" description="Choose the company default used when a new Helpdesk ticket is created. Lookup defaults are configured on the Statuses, Priorities, Categories, Teams and Projects sections.">
<x-slot:form>
<form wire:submit="saveDefaultHelpdeskCompany" class="grid gap-4 md:grid-cols-3">
<flux:select wire:model="default_helpdesk_company_id" label="Default company">
<option value="">No default company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">
{{ $company->name }}{{ ($company->is_default_helpdesk_company ?? false) ? ' — Default' : '' }}
</option>
@endforeach
</flux:select>
<div class="md:col-span-3 flex gap-2">
<flux:button type="submit" variant="primary">Save default company</flux:button>
</div>
</form>
</x-slot:form>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<p class="font-semibold">Lookup defaults</p>
<p class="mt-1">Set the default Status, Priority, Category, Team and Project from their own settings sections. Only one default per company/global scope is kept for each lookup type.</p>
</div>
</x-helpdesk::settings-section>
@endif
@if ($section === 'statuses')
<x-helpdesk::settings-section title="Statuses" description="Control workflow stages and mark which statuses close tickets.">
<x-slot:form>
@@ -70,6 +98,10 @@
<flux:input wire:model="statusForm.color" label="Badge colour" />
<flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" />
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-300"> Closed status</label>
<label class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="statusForm.is_default" class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default status for new tickets
</label>
<div class="md:col-span-5 flex gap-2"><flux:button type="submit" variant="primary">Save status</flux:button><flux:button type="button" wire:click="resetStatusForm" variant="ghost">Reset</flux:button></div>
</form>
</x-slot:form>
@@ -85,6 +117,10 @@
<flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="priorityForm.color" label="Badge colour" />
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" />
<label class="flex items-end gap-2 pb-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="priorityForm.is_default" class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default for new tickets
</label>
<div class="md:col-span-4 flex gap-2"><flux:button type="submit" variant="primary">Save priority</flux:button><flux:button type="button" wire:click="resetPriorityForm" variant="ghost">Reset</flux:button></div>
</form>
</x-slot:form>
@@ -101,6 +137,10 @@
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="categoryForm.color" label="Badge colour" />
<div class="md:col-span-4"><flux:textarea wire:model="categoryForm.description" label="Description" rows="3" /></div>
<label class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="categoryForm.is_default" class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default category for new tickets
</label>
<div class="md:col-span-4 flex gap-2"><flux:button type="submit" variant="primary">Save category</flux:button><flux:button type="button" wire:click="resetCategoryForm" variant="ghost">Reset</flux:button></div>
</form>
</x-slot:form>
@@ -117,6 +157,10 @@
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="teamForm.color" label="Badge colour" />
<div class="md:col-span-4"><flux:textarea wire:model="teamForm.description" label="Description" rows="3" /></div>
<label class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="teamForm.is_default" class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default team for new tickets
</label>
<div class="md:col-span-4 flex gap-2"><flux:button type="submit" variant="primary">Save team</flux:button><flux:button type="button" wire:click="resetTeamForm" variant="ghost">Reset</flux:button></div>
</form>
</x-slot:form>
@@ -134,6 +178,10 @@
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="projectForm.color" label="Badge colour" />
<div class="md:col-span-5"><flux:textarea wire:model="projectForm.description" label="Description" rows="3" /></div>
<label class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="projectForm.is_default" class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default project for new tickets
</label>
<div class="md:col-span-5 flex gap-2"><flux:button type="submit" variant="primary">Save project</flux:button><flux:button type="button" wire:click="resetProjectForm" variant="ghost">Reset</flux:button></div>
</form>
</x-slot:form>
@@ -21,6 +21,41 @@
font-weight: 800 !important;
z-index: 5;
}
.helpdesk-ticket-form input,
.helpdesk-ticket-form textarea,
.helpdesk-ticket-form select {
background-color: #09090b !important;
color: #f4f4f5 !important;
caret-color: #f4f4f5;
}
.helpdesk-ticket-form input:focus,
.helpdesk-ticket-form textarea:focus,
.helpdesk-ticket-form select:focus {
background-color: #09090b !important;
color: #f4f4f5 !important;
}
.helpdesk-ticket-form input:-webkit-autofill,
.helpdesk-ticket-form input:-webkit-autofill:hover,
.helpdesk-ticket-form input:-webkit-autofill:focus,
.helpdesk-ticket-form textarea:-webkit-autofill,
.helpdesk-ticket-form textarea:-webkit-autofill:hover,
.helpdesk-ticket-form textarea:-webkit-autofill:focus,
.helpdesk-ticket-form select:-webkit-autofill,
.helpdesk-ticket-form select:-webkit-autofill:hover,
.helpdesk-ticket-form select:-webkit-autofill:focus {
-webkit-text-fill-color: #f4f4f5 !important;
box-shadow: 0 0 0 1000px #09090b inset !important;
transition: background-color 9999s ease-in-out 0s;
}
.helpdesk-ticket-form [wire\:loading],
.helpdesk-ticket-form [wire\:loading\.delay],
.helpdesk-ticket-form [wire\:loading\.remove] {
color: inherit;
}
</style>
@include('helpdesk::partials.page-header', [
@@ -29,7 +64,7 @@
'subtitle' => 'Raise a new support request with priority, product/service, project, category, team and full details.',
])
<form wire:submit.prevent="save" enctype="multipart/form-data" class="space-y-6">
<form wire:submit.prevent="save" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6">
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
@@ -45,13 +80,15 @@
Subject <span class="helpdesk-required-star">*</span>
</label>
<textarea
<input
id="subject"
rows="3"
type="text"
maxlength="180"
wire:model.defer="subject"
class="mt-2 block min-h-[92px] w-full resize-y rounded-xl border-zinc-700 bg-zinc-950 px-4 py-3 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-subject"
></textarea>
autocomplete="off"
/>
@error('subject')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@@ -67,7 +104,7 @@
<select id="company_id" wire:model.defer="company_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
<option value="{{ $company->id }}" @selected((int) $company_id === (int) $company->id)>{{ $company->name }}</option>
@endforeach
</select>
@@ -85,7 +122,7 @@
<select id="priority_id" wire:model.defer="priority_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" data-dusk="ticket-priority">
<option value="">Select priority</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
<option value="{{ $priority->id }}" @selected((int) $priority_id === (int) $priority->id)>{{ $priority->name }}</option>
@endforeach
</select>
@@ -102,7 +139,7 @@
<select id="category_id" wire:model.defer="category_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
<option value="{{ $category->id }}" @selected((int) $category_id === (int) $category->id)>{{ $category->name }}</option>
@endforeach
</select>
@@ -119,7 +156,7 @@
<select id="project_id" wire:model.defer="project_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select project</option>
@foreach ($projects as $project)
<option value="{{ $project->id }}">{{ $project->title ?? $project->name }}</option>
<option value="{{ $project->id }}" @selected((int) $project_id === (int) $project->id)>{{ $project->title ?? $project->name }}</option>
@endforeach
</select>
@@ -136,7 +173,7 @@
<select id="team_id" wire:model.defer="team_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
<option value="{{ $team->id }}" @selected((int) $team_id === (int) $team->id)>{{ $team->name }}</option>
@endforeach
</select>
@@ -145,6 +182,25 @@
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="tag_ids">Tags</label>
<select id="tag_ids" wire:model.defer="tag_ids" data-helpdesk-select2 multiple class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
@foreach ($tags as $tag)
<option value="{{ $tag->id }}" @selected(in_array((int) $tag->id, array_map('intval', $tag_ids), true))>{{ $tag->name }}</option>
@endforeach
</select>
@error('tag_ids')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('tag_ids.*')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="due_at">Due date</label>
@@ -64,13 +64,15 @@
Subject <span class="helpdesk-required-star">*</span>
</label>
<textarea
<input
id="subject"
rows="3"
type="text"
maxlength="180"
wire:model.defer="subject"
class="mt-2 block min-h-[92px] w-full resize-y rounded-xl border-zinc-700 bg-zinc-950 px-4 py-3 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-edit-subject"
></textarea>
autocomplete="off"
/>
@error('subject')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@@ -194,6 +196,25 @@
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="tag_ids">Tags</label>
<select id="tag_ids" wire:model.defer="tag_ids" data-helpdesk-select2 multiple class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
@foreach ($tags as $tag)
<option value="{{ $tag->id }}">{{ $tag->name }}</option>
@endforeach
</select>
@error('tag_ids')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('tag_ids.*')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="xl:col-span-2">
<label class="block text-sm font-semibold text-zinc-200" for="due_at">Due date</label>
@@ -9,7 +9,7 @@
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.index')" wire:navigate variant="ghost" icon="chart-bar">Dashboard</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary">New ticket</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary" class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-500">New ticket</flux:button>
</div>
</div>
</div>
@@ -1,7 +1,8 @@
@once
<style>
.select2-container { width: 100% !important; }
.select2-container--default .select2-selection--single {
.select2-container--default .select2-selection--single,
.select2-container--default .select2-selection--multiple {
min-height: 2.75rem;
border-radius: 0.75rem;
border-color: rgb(63 63 70);
@@ -10,6 +11,21 @@
display: flex;
align-items: center;
}
.select2-container--default .select2-selection--multiple {
align-items: flex-start;
padding: 0.25rem 0.5rem;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice {
border-color: rgb(59 130 246);
background: rgb(30 64 175);
color: rgb(255 255 255);
border-radius: 9999px;
padding: 0.15rem 0.5rem;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: rgb(255 255 255);
margin-right: 0.35rem;
}
.select2-container--default .select2-selection--single .select2-selection__rendered {
color: rgb(244 244 245);
line-height: 2.75rem;
@@ -76,10 +92,12 @@
select.select2('destroy');
}
const isMultiple = this.hasAttribute('multiple');
select.select2({
width: '100%',
placeholder: this.querySelector('option[value=""]')?.textContent || 'Select option',
allowClear: true
placeholder: this.querySelector('option[value=""]')?.textContent || (isMultiple ? 'Select options' : 'Select option'),
allowClear: !isMultiple
});
select.off('change.helpdesk').on('change.helpdesk', function () {
+17 -2
View File
@@ -7,6 +7,7 @@ namespace Modules\Helpdesk\Services;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
@@ -33,7 +34,17 @@ final class TicketService
{
return DB::transaction(function () use ($user, $data, $files): Ticket {
$defaultStatusSlug = (string) config('helpdesk.default_status_slug', 'new');
$status = Status::query()->where('slug', $defaultStatusSlug)->first();
$status = ! empty($data['status_id'])
? Status::query()->whereKey((int) $data['status_id'])->first()
: null;
if (! $status && Schema::hasTable('helpdesk_statuses') && Schema::hasColumn('helpdesk_statuses', 'is_default')) {
$status = Status::query()->where('is_default', true)->orderBy('sort_order')->first();
}
if (! $status) {
$status = Status::query()->where('slug', $defaultStatusSlug)->first();
}
if (! $status) {
$status = Status::query()->create([
@@ -63,12 +74,16 @@ final class TicketService
'last_activity_at' => now(),
]);
if (! empty($data['tag_ids']) && is_array($data['tag_ids'])) {
$ticket->tags()->sync(array_values(array_unique(array_map('intval', $data['tag_ids']))));
}
$ticket = $this->automation->apply($ticket, $data);
$ticket = $this->sla->applyPolicy($ticket);
$this->checklists->applyTemplates($ticket);
$this->attachments->storeMany($ticket, $files, (int) $user->id);
$ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'attachments', 'checklistItems']) ?? $ticket;
$ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'attachments', 'checklistItems']) ?? $ticket;
$this->workflow->dispatch($ticket, 'ticket.created', $user);
return $ticket;
+26
View File
@@ -102,3 +102,29 @@ versions:
- Added a defensive workflow tracking-pixel URL fallback so ticket creation cannot fail when route caches are stale.
- Registered the workflow open pixel route in the web route file as a backup to the module provider route.
- Kept module.json version in sync with changelog.yaml.
"1.2.5":
changed:
- Fixed the Helpdesk settings default-company Livewire action by adding saveDefaultHelpdeskCompany to SettingsManager.
- Added default flags for Helpdesk statuses as well as priorities, categories, teams and projects.
- Updated ticket creation to use the configured default status and lookup defaults.
- Added selectable ticket tags to the create ticket workflow and synced tags onto new tickets.
- Kept the one-line ticket subject input and dark form repaint fixes.
- Kept module.json version in sync with changelog.yaml.
"1.2.6":
changed:
- Fixed the Helpdesk settings default-company Livewire method and made the default company property resilient to select empty-string values.
- Confirmed default statuses, priorities, categories, teams and projects are applied during ticket creation.
- Confirmed ticket tags can be assigned on create and edit and are synced to the ticket-tag pivot.
- Kept module.json version in sync with changelog.yaml.
"1.2.7":
changed:
- Changed Helpdesk New ticket actions to use green action styling.
- Hardened ticket creation defaults so configured company, status, priority, category, team and project defaults are applied before rendering and when saving.
- Added explicit selected states for ticket create dropdowns so Select2 displays loaded defaults correctly.
- Allowed TicketService to respect an explicitly supplied default status id while preserving fallback status behaviour.
- Kept module.json version in sync with changelog.yaml.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.2.3"
"version": "1.2.7"
}