Merge branch 'issue/Company_Dusk_Failure'
# Conflicts: # Modules/Helpdesk/changelog.yaml
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('company_addresses', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('addressable');
|
||||||
|
$table->string('label', 80)->default('Registered office');
|
||||||
|
$table->string('attention')->nullable();
|
||||||
|
$table->string('line_1');
|
||||||
|
$table->string('line_2')->nullable();
|
||||||
|
$table->string('line_3')->nullable();
|
||||||
|
$table->string('locality')->comment('City, town or locality.');
|
||||||
|
$table->string('administrative_area')->nullable()->comment('County, state, province or region.');
|
||||||
|
$table->string('postal_code', 32)->nullable();
|
||||||
|
$table->foreignId('country_id')->constrained('countries')->restrictOnDelete();
|
||||||
|
$table->foreignId('country_subdivision_id')->nullable()->constrained('country_subdivisions')->nullOnDelete();
|
||||||
|
$table->decimal('latitude', 10, 7)->nullable();
|
||||||
|
$table->decimal('longitude', 10, 7)->nullable();
|
||||||
|
$table->boolean('is_primary')->default(true);
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['addressable_type', 'addressable_id'], 'company_addresses_one_per_owner_unique');
|
||||||
|
$table->index(['country_id', 'locality']);
|
||||||
|
$table->index(['postal_code']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('company_addresses');
|
||||||
|
}
|
||||||
|
};
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('company_divisions', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('code', 40)->nullable();
|
||||||
|
$table->string('slug');
|
||||||
|
$table->string('status', 32)->default('active')->index();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->unique(['company_id', 'slug']);
|
||||||
|
$table->unique(['company_id', 'code']);
|
||||||
|
$table->index(['company_id', 'status', 'name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('company_divisions');
|
||||||
|
}
|
||||||
|
};
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('company_division_user', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||||
|
$table->foreignId('company_division_id')->constrained('company_divisions')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('role', 80)->default('member')->index();
|
||||||
|
$table->boolean('is_director')->default(false)->index();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['company_division_id', 'user_id']);
|
||||||
|
$table->index(['company_id', 'user_id']);
|
||||||
|
$table->index(['company_id', 'is_director']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('company_division_user');
|
||||||
|
}
|
||||||
|
};
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('company_director_assignments', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||||
|
$table->foreignId('company_division_id')->nullable()->constrained('company_divisions')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('coverage_type', 32)->default('company')->index();
|
||||||
|
$table->date('starts_at')->nullable();
|
||||||
|
$table->date('ends_at')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['company_id', 'company_division_id', 'user_id'], 'company_director_coverage_unique');
|
||||||
|
$table->index(['company_id', 'coverage_type']);
|
||||||
|
$table->index(['user_id', 'coverage_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('company_director_assignments');
|
||||||
|
}
|
||||||
|
};
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('warehouses', function (Blueprint $table): void {
|
||||||
|
if (! Schema::hasColumn('warehouses', 'division_id')) {
|
||||||
|
$table->foreignId('division_id')->nullable()->after('company_id')->constrained('company_divisions')->nullOnDelete();
|
||||||
|
$table->index(['company_id', 'division_id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('warehouses', function (Blueprint $table): void {
|
||||||
|
if (Schema::hasColumn('warehouses', 'division_id')) {
|
||||||
|
$table->dropConstrainedForeignId('division_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('country_id')
|
||||||
|
->nullable()
|
||||||
|
->after('billing_email')
|
||||||
|
->constrained('countries')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('country_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Company\Models\CompanyDivision;
|
||||||
|
use Modules\Warehouse\app\Models\Warehouse;
|
||||||
|
|
||||||
|
class CompanyAddressController extends Controller
|
||||||
|
{
|
||||||
|
public function updateCompany(Request $request, Company $company)
|
||||||
|
{
|
||||||
|
$this->upsertAddress($request, $company);
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Company address updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateDivision(Request $request, Company $company, CompanyDivision $division)
|
||||||
|
{
|
||||||
|
abort_unless($division->company_id === $company->id, 404);
|
||||||
|
|
||||||
|
$this->upsertAddress($request, $division, 'Operating address');
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Division address updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateWarehouse(Request $request, Company $company, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
abort_unless($warehouse->company_id === $company->id, 404);
|
||||||
|
|
||||||
|
$this->upsertAddress($request, $warehouse, 'Warehouse address');
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse address updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertAddress(Request $request, object $owner, string $defaultLabel = 'Registered office'): void
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'label' => ['nullable', 'string', 'max:80'],
|
||||||
|
'attention' => ['nullable', 'string', 'max:255'],
|
||||||
|
'line_1' => ['required', 'string', 'max:255'],
|
||||||
|
'line_2' => ['nullable', 'string', 'max:255'],
|
||||||
|
'line_3' => ['nullable', 'string', 'max:255'],
|
||||||
|
'locality' => ['required', 'string', 'max:255'],
|
||||||
|
'administrative_area' => ['nullable', 'string', 'max:255'],
|
||||||
|
'postal_code' => ['nullable', 'string', 'max:32'],
|
||||||
|
'country_id' => ['required', Rule::exists('countries', 'id')->where('is_active', true)],
|
||||||
|
'country_subdivision_id' => ['nullable', 'exists:country_subdivisions,id'],
|
||||||
|
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
|
||||||
|
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['label'] = $data['label'] ?: $defaultLabel;
|
||||||
|
$data['is_primary'] = true;
|
||||||
|
|
||||||
|
$owner->address()->updateOrCreate([], $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Countries\Models\Country;
|
||||||
|
|
||||||
|
class CompanyController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display companies.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('company::index', [
|
||||||
|
'companies' => Company::query()
|
||||||
|
->with(['address.country', 'country'])
|
||||||
|
->withCount(['users', 'warehouses', 'licenseKeys', 'divisions'])
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(20),
|
||||||
|
'countries' => $this->countries(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the create company screen.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('company::create', [
|
||||||
|
'company' => new Company([
|
||||||
|
'status' => 'active',
|
||||||
|
'timezone' => 'Europe/London',
|
||||||
|
]),
|
||||||
|
'countries' => $this->countries(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a company tenant.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$company = Company::create($this->validated($request));
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('platform.companies.index')
|
||||||
|
->with('status', 'Company created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a company workbench.
|
||||||
|
*/
|
||||||
|
public function show(Company $company)
|
||||||
|
{
|
||||||
|
$company->load([
|
||||||
|
'address.country',
|
||||||
|
'country',
|
||||||
|
'divisions.address.country',
|
||||||
|
'divisions.warehouses',
|
||||||
|
'divisions.users',
|
||||||
|
'warehouses.address.country',
|
||||||
|
'warehouses.division',
|
||||||
|
'directorAssignments.user',
|
||||||
|
'directorAssignments.division',
|
||||||
|
])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']);
|
||||||
|
|
||||||
|
return view('company::show', [
|
||||||
|
'company' => $company,
|
||||||
|
'recentUsers' => $company->users()->latest('id')->limit(5)->get(),
|
||||||
|
'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(),
|
||||||
|
'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(),
|
||||||
|
'countries' => $this->countries(),
|
||||||
|
'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']),
|
||||||
|
'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the edit company screen.
|
||||||
|
*/
|
||||||
|
public function edit(Company $company)
|
||||||
|
{
|
||||||
|
return view('company::edit', [
|
||||||
|
'company' => $company->loadMissing(['address.country', 'country']),
|
||||||
|
'countries' => $this->countries(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a company tenant.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Company $company)
|
||||||
|
{
|
||||||
|
$company->update($this->validated($request, $company));
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('platform.companies.show', $company)
|
||||||
|
->with('status', 'Company updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a company tenant.
|
||||||
|
*/
|
||||||
|
public function destroy(Company $company)
|
||||||
|
{
|
||||||
|
abort_if($company->is_system_company, 403, 'The system company cannot be deleted.');
|
||||||
|
|
||||||
|
$company->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('platform.companies.index')
|
||||||
|
->with('status', 'Company deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validated(Request $request, ?Company $company = null): array
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'billing_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])],
|
||||||
|
'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['slug'] = Str::slug($data['name']);
|
||||||
|
$data['timezone'] = $company?->timezone ?: 'Europe/London';
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countries()
|
||||||
|
{
|
||||||
|
return Country::query()
|
||||||
|
->active()
|
||||||
|
->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC")
|
||||||
|
->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Company\Models\CompanyDivision;
|
||||||
|
|
||||||
|
class CompanyDivisionController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Company $company)
|
||||||
|
{
|
||||||
|
$division = $company->divisions()->create($this->validated($request, $company));
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', "Division {$division->name} created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Company $company, CompanyDivision $division)
|
||||||
|
{
|
||||||
|
abort_unless($division->company_id === $company->id, 404);
|
||||||
|
|
||||||
|
$division->update($this->validated($request, $company, $division));
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Division updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Company $company, CompanyDivision $division)
|
||||||
|
{
|
||||||
|
abort_unless($division->company_id === $company->id, 404);
|
||||||
|
|
||||||
|
abort_if($division->warehouses()->exists(), 422, 'Move warehouses before deleting this division.');
|
||||||
|
|
||||||
|
$division->delete();
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Division deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validated(Request $request, Company $company, ?CompanyDivision $division = null): array
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'code' => ['nullable', 'string', 'max:40', Rule::unique('company_divisions', 'code')->where('company_id', $company->id)->ignore($division?->id)],
|
||||||
|
'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['slug'] = Str::slug($data['name']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Company\Models\CompanyDivision;
|
||||||
|
use Modules\Company\Models\CompanyDirectorAssignment;
|
||||||
|
|
||||||
|
class CompanyMembershipController extends Controller
|
||||||
|
{
|
||||||
|
public function assignDivision(Request $request, Company $company)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)],
|
||||||
|
'company_division_id' => ['required', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)],
|
||||||
|
'role' => ['nullable', 'string', 'max:80'],
|
||||||
|
'is_director' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$division = CompanyDivision::where('company_id', $company->id)->findOrFail($data['company_division_id']);
|
||||||
|
$division->users()->syncWithoutDetaching([
|
||||||
|
$data['user_id'] => [
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'role' => $data['role'] ?? 'member',
|
||||||
|
'is_director' => (bool) ($data['is_director'] ?? false),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ((bool) ($data['is_director'] ?? false)) {
|
||||||
|
CompanyDirectorAssignment::updateOrCreate([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'company_division_id' => $division->id,
|
||||||
|
'user_id' => $data['user_id'],
|
||||||
|
], ['coverage_type' => 'division']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Division membership updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignDirector(Request $request, Company $company)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)],
|
||||||
|
'coverage_type' => ['required', Rule::in(['company', 'division'])],
|
||||||
|
'company_division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)],
|
||||||
|
'starts_at' => ['nullable', 'date'],
|
||||||
|
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($data['coverage_type'] === 'company') {
|
||||||
|
$data['company_division_id'] = null;
|
||||||
|
} else {
|
||||||
|
abort_if(empty($data['company_division_id']), 422, 'Division coverage requires a division.');
|
||||||
|
}
|
||||||
|
|
||||||
|
CompanyDirectorAssignment::updateOrCreate([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'company_division_id' => $data['company_division_id'],
|
||||||
|
'user_id' => $data['user_id'],
|
||||||
|
], [
|
||||||
|
'coverage_type' => $data['coverage_type'],
|
||||||
|
'starts_at' => $data['starts_at'] ?? null,
|
||||||
|
'ends_at' => $data['ends_at'] ?? null,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeDirector(Company $company, CompanyDirectorAssignment $assignment)
|
||||||
|
{
|
||||||
|
abort_unless($assignment->company_id === $company->id, 404);
|
||||||
|
|
||||||
|
$assignment->delete();
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage removed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Warehouse\app\Models\Warehouse;
|
||||||
|
|
||||||
|
class CompanyWarehouseAssignmentController extends Controller
|
||||||
|
{
|
||||||
|
public function update(Request $request, Company $company, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
abort_unless($warehouse->company_id === $company->id, 404);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$warehouse->update(['division_id' => $data['division_id'] ?? null]);
|
||||||
|
|
||||||
|
return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse division assignment updated.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Platform\app\Models;
|
namespace Modules\Company\Models;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Modules\Platform\app\Models\LicenseKey;
|
||||||
|
use Modules\Countries\Models\Country;
|
||||||
|
use Modules\Platform\app\Models\Payment;
|
||||||
use Modules\Warehouse\app\Models\Warehouse;
|
use Modules\Warehouse\app\Models\Warehouse;
|
||||||
|
|
||||||
class Company extends Model
|
class Company extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'uuid', 'name', 'slug', 'status', 'timezone', 'settings',
|
'uuid', 'name', 'slug', 'status', 'timezone', 'settings',
|
||||||
'is_system_company', 'stripe_customer_id', 'billing_email',
|
'is_system_company', 'stripe_customer_id', 'billing_email', 'country_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -34,6 +38,28 @@ class Company extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function country(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Country::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function address(): MorphOne
|
||||||
|
{
|
||||||
|
return $this->morphOne(CompanyAddress::class, 'addressable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function divisions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CompanyDivision::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function directorAssignments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CompanyDirectorAssignment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function users(): HasMany
|
public function users(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(User::class);
|
return $this->hasMany(User::class);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Modules\Countries\Models\Country;
|
||||||
|
use Modules\Countries\Models\CountrySubdivision;
|
||||||
|
|
||||||
|
class CompanyAddress extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'addressable_type', 'addressable_id', 'label', 'attention', 'line_1', 'line_2', 'line_3',
|
||||||
|
'locality', 'administrative_area', 'postal_code', 'country_id', 'country_subdivision_id',
|
||||||
|
'latitude', 'longitude', 'is_primary', 'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_primary' => 'boolean',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'latitude' => 'decimal:7',
|
||||||
|
'longitude' => 'decimal:7',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function addressable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function country(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Country::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subdivision(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CountrySubdivision::class, 'country_subdivision_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedAttribute(): string
|
||||||
|
{
|
||||||
|
return collect([
|
||||||
|
$this->attention,
|
||||||
|
$this->line_1,
|
||||||
|
$this->line_2,
|
||||||
|
$this->line_3,
|
||||||
|
$this->locality,
|
||||||
|
$this->administrative_area,
|
||||||
|
$this->postal_code,
|
||||||
|
$this->country?->effective_name ?? $this->country?->name,
|
||||||
|
])->filter()->implode(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class CompanyDirectorAssignment extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'company_id', 'company_division_id', 'user_id', 'coverage_type', 'starts_at', 'ends_at', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'starts_at' => 'date',
|
||||||
|
'ends_at' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function company(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Company::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function division(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CompanyDivision::class, 'company_division_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Modules\Warehouse\app\Models\Warehouse;
|
||||||
|
|
||||||
|
class CompanyDivision extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'company_id', 'uuid', 'name', 'code', 'slug', 'status', 'description', 'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (CompanyDivision $division): void {
|
||||||
|
$division->uuid ??= (string) Str::uuid();
|
||||||
|
$division->slug ??= Str::slug($division->name);
|
||||||
|
$division->status ??= 'active';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function company(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Company::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function address(): MorphOne
|
||||||
|
{
|
||||||
|
return $this->morphOne(CompanyAddress::class, 'addressable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function users(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'company_division_user')
|
||||||
|
->withPivot(['company_id', 'role', 'is_director'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warehouses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Warehouse::class, 'division_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function directorAssignments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Company\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class CompanyServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadRoutesFrom(__DIR__ . '/../Routes/web.php');
|
||||||
|
$this->loadViewsFrom(__DIR__ . '/../Resources/views', 'company');
|
||||||
|
$this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Company name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value="{{ old('name', $company->name) }}"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('name')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="billing_email" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Billing email</label>
|
||||||
|
<input
|
||||||
|
id="billing_email"
|
||||||
|
type="email"
|
||||||
|
name="billing_email"
|
||||||
|
value="{{ old('billing_email', $company->billing_email) }}"
|
||||||
|
placeholder="billing@example.com"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
>
|
||||||
|
@error('billing_email')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Status</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
name="status"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="active" @selected(old('status', $company->status) === 'active')>Active</option>
|
||||||
|
<option value="suspended" @selected(old('status', $company->status) === 'suspended')>Suspended</option>
|
||||||
|
<option value="inactive" @selected(old('status', $company->status) === 'inactive')>Inactive</option>
|
||||||
|
</select>
|
||||||
|
@error('status')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="country_id" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Country</label>
|
||||||
|
<select
|
||||||
|
id="country_id"
|
||||||
|
name="country_id"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="Search and select a country"
|
||||||
|
class="wms-select2-control"
|
||||||
|
>
|
||||||
|
<option value=""></option>
|
||||||
|
@foreach ($countries as $country)
|
||||||
|
<option value="{{ $country->id }}" @selected((int) old('country_id', $company->address?->country_id) === $country->id)>
|
||||||
|
{{ $country->display_name ?: $country->common_name ?: $country->name }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('country_id')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
<button class="rounded-xl bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||||
|
Save company
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ $company->exists ? route('platform.companies.show', $company) : route('platform.companies.index') }}"
|
||||||
|
class="rounded-xl border border-zinc-300 px-4 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<x-layouts::app :title="__('Create company')">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-wms.page-hero
|
||||||
|
eyebrow="Platform Administration"
|
||||||
|
title="Create company"
|
||||||
|
description="Add a new tenant company and assign its country."
|
||||||
|
icon="fa-solid fa-building-circle-plus"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<form method="POST" action="{{ route('platform.companies.store') }}">
|
||||||
|
@include('company::_form')
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<x-layouts::app :title="__('Edit company')">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-wms.page-hero
|
||||||
|
eyebrow="Platform Administration"
|
||||||
|
title="Edit company"
|
||||||
|
description="Update company details and country assignment."
|
||||||
|
icon="fa-solid fa-building"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<form method="POST" action="{{ route('platform.companies.update', $company) }}">
|
||||||
|
@method('PUT')
|
||||||
|
@include('company::_form')
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<x-layouts::app :title="__('Companies')">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-wms.page-hero
|
||||||
|
eyebrow="Platform Administration"
|
||||||
|
title="Companies"
|
||||||
|
description="Manage tenant companies and keep every company’s users, warehouses and records isolated."
|
||||||
|
icon="fa-solid fa-building"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="rounded-2xl bg-green-50 p-4 text-sm font-semibold text-green-700 ring-1 ring-green-200 dark:bg-green-950 dark:text-green-300 dark:ring-green-900">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Create company</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Add a new tenant without leaving the administration index.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ route('platform.companies.create') }}"
|
||||||
|
class="rounded-xl border border-zinc-300 px-4 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Open full form
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('platform.companies.store') }}" class="mt-6">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1.15fr)_minmax(12rem,.75fr)_minmax(18rem,1.15fr)_auto] xl:items-end">
|
||||||
|
<div>
|
||||||
|
<label for="quick_company_name" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Company</label>
|
||||||
|
<input
|
||||||
|
id="quick_company_name"
|
||||||
|
name="name"
|
||||||
|
value="{{ old('name') }}"
|
||||||
|
placeholder="Company name"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('name')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="quick_billing_email" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Billing email</label>
|
||||||
|
<input
|
||||||
|
id="quick_billing_email"
|
||||||
|
name="billing_email"
|
||||||
|
value="{{ old('billing_email') }}"
|
||||||
|
placeholder="billing@example.com"
|
||||||
|
type="email"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
>
|
||||||
|
@error('billing_email')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="quick_status" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Status</label>
|
||||||
|
<select
|
||||||
|
id="quick_status"
|
||||||
|
name="status"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="active" @selected(old('status', 'active') === 'active')>Active</option>
|
||||||
|
<option value="suspended" @selected(old('status') === 'suspended')>Suspended</option>
|
||||||
|
<option value="inactive" @selected(old('status') === 'inactive')>Inactive</option>
|
||||||
|
</select>
|
||||||
|
@error('status')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="quick_country_id" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Country</label>
|
||||||
|
<select
|
||||||
|
id="quick_country_id"
|
||||||
|
name="country_id"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="Search and select country"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<option value=""></option>
|
||||||
|
@foreach($countries as $country)
|
||||||
|
<option value="{{ $country->id }}" @selected(old('country_id') == $country->id)>
|
||||||
|
{{ $country->display_name ?: $country->common_name ?: $country->name }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('country_id')
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="h-12 rounded-xl bg-green-600 px-6 text-sm font-bold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<th class="p-4 text-left">Name</th>
|
||||||
|
<th class="p-4 text-left">Users</th>
|
||||||
|
<th class="p-4 text-left">Divisions</th>
|
||||||
|
<th class="p-4 text-left">Warehouses</th>
|
||||||
|
<th class="p-4 text-left">Licences</th>
|
||||||
|
<th class="p-4 text-left">Status</th>
|
||||||
|
<th class="p-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
@forelse ($companies as $company)
|
||||||
|
<tr class="border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<td class="p-4 font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{{ $company->name }}
|
||||||
|
</td>
|
||||||
|
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->users_count }}</td>
|
||||||
|
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->divisions_count ?? 0 }}</td>
|
||||||
|
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->warehouses_count }}</td>
|
||||||
|
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->license_keys_count }}</td>
|
||||||
|
<td class="p-4">
|
||||||
|
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold
|
||||||
|
@if ($company->is_system_company)
|
||||||
|
bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300
|
||||||
|
@elseif ($company->status === 'active')
|
||||||
|
bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300
|
||||||
|
@else
|
||||||
|
bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300
|
||||||
|
@endif
|
||||||
|
">
|
||||||
|
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-4">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<a href="{{ route('platform.companies.show', $company) }}" class="rounded-lg border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800">View</a>
|
||||||
|
<a href="{{ route('platform.companies.edit', $company) }}" class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Edit</a>
|
||||||
|
|
||||||
|
@unless ($company->is_system_company)
|
||||||
|
<form method="POST" action="{{ route('platform.companies.destroy', $company) }}" onsubmit="return confirm('Delete this company? This cannot be undone.');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700">Delete</button>
|
||||||
|
</form>
|
||||||
|
@endunless
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="p-8 text-center text-zinc-500 dark:text-zinc-400">No companies found.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ $companies->links() }}
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@php
|
||||||
|
$address = $address ?? null;
|
||||||
|
$inputClass = 'mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white';
|
||||||
|
$countryFieldId = 'address_country_id_' . md5(($defaultLabel ?? 'address') . '_' . ($address?->id ?? 'new'));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="address_label" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Label</label>
|
||||||
|
<input id="address_label" name="label" value="{{ old('label', $address?->label ?? $defaultLabel ?? 'Registered office') }}" class="{{ $inputClass }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="address_attention" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Attention</label>
|
||||||
|
<input id="address_attention" name="attention" value="{{ old('attention', $address?->attention) }}" class="{{ $inputClass }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="address_line_1" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Address line 1</label>
|
||||||
|
<input id="address_line_1" name="line_1" value="{{ old('line_1', $address?->line_1) }}" class="{{ $inputClass }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="address_line_2" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Address line 2</label>
|
||||||
|
<input id="address_line_2" name="line_2" value="{{ old('line_2', $address?->line_2) }}" class="{{ $inputClass }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="address_line_3" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Address line 3</label>
|
||||||
|
<input id="address_line_3" name="line_3" value="{{ old('line_3', $address?->line_3) }}" class="{{ $inputClass }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="address_locality" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Town / City</label>
|
||||||
|
<input id="address_locality" name="locality" value="{{ old('locality', $address?->locality) }}" class="{{ $inputClass }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="address_administrative_area" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">County / State / Region</label>
|
||||||
|
<input id="address_administrative_area" name="administrative_area" value="{{ old('administrative_area', $address?->administrative_area) }}" class="{{ $inputClass }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="address_postal_code" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Postcode / ZIP</label>
|
||||||
|
<input id="address_postal_code" name="postal_code" value="{{ old('postal_code', $address?->postal_code) }}" class="{{ $inputClass }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="{{ $countryFieldId }}" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
||||||
|
Country
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
id="{{ $countryFieldId }}"
|
||||||
|
name="country_id"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="Search and select country"
|
||||||
|
class="wms-select2-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value=""></option>
|
||||||
|
|
||||||
|
@foreach ($countries as $country)
|
||||||
|
<option
|
||||||
|
value="{{ $country->id }}"
|
||||||
|
@selected((int) old('country_id', $address?->country_id) === $country->id)
|
||||||
|
>
|
||||||
|
{{ $country->display_name ?: $country->common_name ?: $country->name }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button class="h-12 rounded-xl bg-green-600 px-5 text-sm font-semibold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||||
|
Save address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<x-layouts::app :title="$company->name">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-wms.page-hero eyebrow="Company Workbench" :title="$company->name" description="Manage the tenant profile, address, divisions, director coverage and warehouse alignment." icon="fa-solid fa-building" />
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Users</p><p class="mt-2 text-3xl font-bold">{{ $company->users_count }}</p></div>
|
||||||
|
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Divisions</p><p class="mt-2 text-3xl font-bold">{{ $company->divisions_count }}</p></div>
|
||||||
|
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Warehouses</p><p class="mt-2 text-3xl font-bold">{{ $company->warehouses_count }}</p></div>
|
||||||
|
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Licences</p><p class="mt-2 text-3xl font-bold">{{ $company->license_keys_count }}</p></div>
|
||||||
|
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Payments</p><p class="mt-2 text-3xl font-bold">{{ $company->payments_count }}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-3">
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800 xl:col-span-2">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div><h2 class="text-lg font-bold text-zinc-900 dark:text-white">Company profile</h2><p class="mt-1 text-sm text-zinc-500">Core tenant identity and operational status.</p></div>
|
||||||
|
<a href="{{ route('platform.companies.edit', $company) }}" class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white">Edit company</a>
|
||||||
|
</div>
|
||||||
|
<dl class="mt-6 grid gap-5 md:grid-cols-2">
|
||||||
|
<div><dt class="text-sm text-zinc-500">Billing email</dt><dd class="font-semibold">{{ $company->billing_email ?? '—' }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-zinc-500">Status</dt><dd class="font-semibold">{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-zinc-500">Timezone</dt><dd class="font-semibold">{{ $company->timezone }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-zinc-500">Country</dt><dd class="font-semibold">{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: '—' }}</dd></div>
|
||||||
|
<div><dt class="text-sm text-zinc-500">Slug</dt><dd class="font-semibold">{{ $company->slug }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Quick actions</h2>
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<a href="{{ route('platform.companies.index') }}" class="block rounded-xl border border-zinc-300 px-4 py-2 text-center text-sm font-semibold">Back to companies</a>
|
||||||
|
@unless ($company->is_system_company)
|
||||||
|
<form method="POST" action="{{ route('platform.companies.destroy', $company) }}" onsubmit="return confirm('Delete this company? This cannot be undone.');">@csrf @method('DELETE')<button class="w-full rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white">Delete company</button></form>
|
||||||
|
@endunless
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Company address</h2>
|
||||||
|
@if ($company->address)
|
||||||
|
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">{{ $company->address->formatted }}</p>
|
||||||
|
@else
|
||||||
|
<p class="mt-2 text-sm text-zinc-500">No company address has been recorded.</p>
|
||||||
|
@endif
|
||||||
|
<form method="POST" action="{{ route('platform.companies.address.update', $company) }}" class="mt-5">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
@include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office'])
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Create division</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('platform.companies.divisions.store', $company) }}" class="mt-5 space-y-4">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="division_name" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Division name</label>
|
||||||
|
<input
|
||||||
|
id="division_name"
|
||||||
|
name="name"
|
||||||
|
value="{{ old('name') }}"
|
||||||
|
placeholder="Division name"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="division_code" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Code</label>
|
||||||
|
<input
|
||||||
|
id="division_code"
|
||||||
|
name="code"
|
||||||
|
value="{{ old('code') }}"
|
||||||
|
placeholder="Optional code"
|
||||||
|
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="division_status" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Status</label>
|
||||||
|
<select
|
||||||
|
id="division_status"
|
||||||
|
name="status"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="Select status"
|
||||||
|
class="wms-select2-control"
|
||||||
|
>
|
||||||
|
<option value="active" @selected(old('status', 'active') === 'active')>Active</option>
|
||||||
|
<option value="suspended" @selected(old('status') === 'suspended')>Suspended</option>
|
||||||
|
<option value="inactive" @selected(old('status') === 'inactive')>Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button class="h-12 w-full rounded-xl bg-green-600 px-4 text-sm font-bold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||||
|
Create division
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="division_description" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="division_description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
rows="3"
|
||||||
|
class="mt-2 w-full rounded-xl border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||||
|
>{{ old('description') }}</textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Director coverage</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('platform.companies.director-assignments.store', $company) }}" class="mt-5 space-y-4">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="director_user_id" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">User</label>
|
||||||
|
<select
|
||||||
|
id="director_user_id"
|
||||||
|
name="user_id"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="Select user"
|
||||||
|
class="wms-select2-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select user</option>
|
||||||
|
@foreach($companyUsers as $user)
|
||||||
|
<option value="{{ $user->id }}" @selected(old('user_id') == $user->id)>
|
||||||
|
{{ $user->name }}{{ $user->email ? ' — '.$user->email : '' }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="director_coverage_type" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Coverage</label>
|
||||||
|
<select
|
||||||
|
id="director_coverage_type"
|
||||||
|
name="coverage_type"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="Select coverage"
|
||||||
|
class="wms-select2-control"
|
||||||
|
>
|
||||||
|
<option value="company" @selected(old('coverage_type', 'company') === 'company')>Whole company</option>
|
||||||
|
<option value="division" @selected(old('coverage_type') === 'division')>Specific division</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label for="director_company_division_id" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Division</label>
|
||||||
|
<select
|
||||||
|
id="director_company_division_id"
|
||||||
|
name="company_division_id"
|
||||||
|
data-wms-select2
|
||||||
|
data-placeholder="No division"
|
||||||
|
class="wms-select2-control"
|
||||||
|
>
|
||||||
|
<option value="">No division</option>
|
||||||
|
@foreach($company->divisions->sortBy('name') as $division)
|
||||||
|
<option value="{{ $division->id }}" @selected(old('company_division_id') == $division->id)>{{ $division->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button class="h-12 w-full rounded-xl bg-green-600 px-4 text-sm font-bold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||||
|
Assign director
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
@forelse($company->directorAssignments as $assignment)
|
||||||
|
<div class="flex items-center justify-between rounded-2xl border border-zinc-200 p-3 dark:border-zinc-800">
|
||||||
|
<span>{{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }}</span>
|
||||||
|
<form method="POST" action="{{ route('platform.companies.director-assignments.destroy', [$company, $assignment]) }}">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button class="text-sm font-semibold text-red-600">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-zinc-500">No director coverage recorded.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Warehouse division and address alignment</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">Warehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.</p>
|
||||||
|
<div class="mt-5 space-y-5">
|
||||||
|
@forelse($company->warehouses as $warehouse)
|
||||||
|
<div class="rounded-2xl border border-zinc-200 p-4 dark:border-zinc-800">
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-zinc-900 dark:text-white">{{ $warehouse->name }}</p>
|
||||||
|
<p class="text-sm text-zinc-500">{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}</p>
|
||||||
|
@if ($warehouse->address)
|
||||||
|
<p class="mt-2 text-xs text-zinc-500">{{ $warehouse->address->formatted }}</p>
|
||||||
|
@else
|
||||||
|
<p class="mt-2 text-xs text-zinc-500">No warehouse address recorded.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('platform.companies.warehouses.division.update', [$company, $warehouse]) }}" class="contents">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<select name="division_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||||
|
<option value="">Company-level warehouse</option>
|
||||||
|
@foreach($company->divisions as $divisionOption)
|
||||||
|
<option value="{{ $divisionOption->id }}" @selected($warehouse->division_id === $divisionOption->id)>{{ $divisionOption->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<button class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white">Update assignment</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-4 rounded-2xl bg-zinc-50 p-4 dark:bg-zinc-950">
|
||||||
|
<summary class="cursor-pointer text-sm font-semibold text-zinc-900 dark:text-white">Edit warehouse address</summary>
|
||||||
|
<form method="POST" action="{{ route('platform.companies.warehouses.address.update', [$company, $warehouse]) }}" class="mt-4">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
@include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address'])
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-zinc-500">No warehouses exist for this company yet.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-xl font-bold text-zinc-900 dark:text-white">Divisions</h2>
|
||||||
|
@forelse($company->divisions as $division)
|
||||||
|
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div><h3 class="text-lg font-bold">{{ $division->name }}</h3><p class="text-sm text-zinc-500">{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses</p></div>
|
||||||
|
<form method="POST" action="{{ route('platform.companies.divisions.destroy', [$company, $division]) }}" onsubmit="return confirm('Delete this division?');">@csrf @method('DELETE')<button class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white">Delete division</button></form>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('platform.companies.divisions.update', [$company, $division]) }}" class="mt-5 space-y-4">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-4 md:items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Division name</label>
|
||||||
|
<input name="name" value="{{ $division->name }}" class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Code</label>
|
||||||
|
<input name="code" value="{{ $division->code }}" class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wms-select2-field">
|
||||||
|
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Status</label>
|
||||||
|
<select name="status" data-wms-select2 data-placeholder="Select status" class="wms-select2-control">
|
||||||
|
<option value="active" @selected($division->status === 'active')>Active</option>
|
||||||
|
<option value="suspended" @selected($division->status === 'suspended')>Suspended</option>
|
||||||
|
<option value="inactive" @selected($division->status === 'inactive')>Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="h-12 rounded-xl bg-blue-600 px-4 text-sm font-bold text-white shadow-sm transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">Update division</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Description</label>
|
||||||
|
<textarea name="description" rows="3" class="mt-2 w-full rounded-xl border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white">{{ $division->description }}</textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="mt-6"><h4 class="font-semibold">Division address</h4><form method="POST" action="{{ route('platform.companies.divisions.address.update', [$company, $division]) }}" class="mt-4">@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])</form></div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="rounded-3xl bg-white p-6 text-sm text-zinc-500 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">No divisions exist. Warehouses can remain directly under the company until divisions are introduced.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Company\Http\Controllers\CompanyAddressController;
|
||||||
|
use Modules\Company\Http\Controllers\CompanyController;
|
||||||
|
use Modules\Company\Http\Controllers\CompanyDivisionController;
|
||||||
|
use Modules\Company\Http\Controllers\CompanyMembershipController;
|
||||||
|
use Modules\Company\Http\Controllers\CompanyWarehouseAssignmentController;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'verified', 'tenant.assigned', 'platform.admin'])
|
||||||
|
->prefix('admin')
|
||||||
|
->name('platform.')
|
||||||
|
->group(function () {
|
||||||
|
Route::resource('companies', CompanyController::class);
|
||||||
|
|
||||||
|
Route::put('companies/{company}/address', [CompanyAddressController::class, 'updateCompany'])->name('companies.address.update');
|
||||||
|
Route::post('companies/{company}/divisions', [CompanyDivisionController::class, 'store'])->name('companies.divisions.store');
|
||||||
|
Route::put('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'update'])->name('companies.divisions.update');
|
||||||
|
Route::delete('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'destroy'])->name('companies.divisions.destroy');
|
||||||
|
Route::put('companies/{company}/divisions/{division}/address', [CompanyAddressController::class, 'updateDivision'])->name('companies.divisions.address.update');
|
||||||
|
|
||||||
|
Route::post('companies/{company}/division-memberships', [CompanyMembershipController::class, 'assignDivision'])->name('companies.division-memberships.store');
|
||||||
|
Route::post('companies/{company}/director-assignments', [CompanyMembershipController::class, 'assignDirector'])->name('companies.director-assignments.store');
|
||||||
|
Route::delete('companies/{company}/director-assignments/{assignment}', [CompanyMembershipController::class, 'removeDirector'])->name('companies.director-assignments.destroy');
|
||||||
|
|
||||||
|
Route::put('companies/{company}/warehouses/{warehouse}/division', [CompanyWarehouseAssignmentController::class, 'update'])->name('companies.warehouses.division.update');
|
||||||
|
Route::put('companies/{company}/warehouses/{warehouse}/address', [CompanyAddressController::class, 'updateWarehouse'])->name('companies.warehouses.address.update');
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
latest: 2026.07.08
|
||||||
|
|
||||||
|
releases:
|
||||||
|
2026.07.08:
|
||||||
|
date: 2026-07-08
|
||||||
|
changes:
|
||||||
|
- Hardened platform company creation with unique slug generation for repeated browser-test runs.
|
||||||
|
- Added deterministic Dusk synchronisation for the inline company creation form.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Company",
|
||||||
|
"alias": "company",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"description": "Tenant company administration module.",
|
||||||
|
"keywords": ["company", "tenant", "platform-administration"],
|
||||||
|
"priority": 0,
|
||||||
|
"providers": [
|
||||||
|
"Modules\\Company\\Providers\\CompanyServiceProvider"
|
||||||
|
],
|
||||||
|
"aliases": {},
|
||||||
|
"files": [],
|
||||||
|
"requires": []
|
||||||
|
}
|
||||||
@@ -13,10 +13,7 @@ use Modules\Countries\Support\CountryUsageReport;
|
|||||||
|
|
||||||
class CountriesController extends Controller
|
class CountriesController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function __invoke(Request $request): View
|
||||||
* Display ISO 3166 country records.
|
|
||||||
*/
|
|
||||||
public function index(Request $request): View
|
|
||||||
{
|
{
|
||||||
$search = trim((string) $request->query('search', ''));
|
$search = trim((string) $request->query('search', ''));
|
||||||
$status = (string) $request->query('status', 'all');
|
$status = (string) $request->query('status', 'all');
|
||||||
@@ -40,80 +37,78 @@ class CountriesController extends Controller
|
|||||||
->when($status === 'active', fn ($query) => $query->where('is_active', true))
|
->when($status === 'active', fn ($query) => $query->where('is_active', true))
|
||||||
->when($status === 'inactive', fn ($query) => $query->where('is_active', false))
|
->when($status === 'inactive', fn ($query) => $query->where('is_active', false))
|
||||||
->when($region !== 'all' && $region !== '', fn ($query) => $query->where('region', $region))
|
->when($region !== 'all' && $region !== '', fn ($query) => $query->where('region', $region))
|
||||||
->ordered()
|
->orderByRaw('LOWER(COALESCE(display_name, common_name, name)) ASC')
|
||||||
->paginate(50)
|
->paginate(50)
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
|
|
||||||
$activeCount = Country::query()->where('is_active', true)->count();
|
$activeCount = Country::query()->where('is_active', true)->count();
|
||||||
$inactiveCount = Country::query()->where('is_active', false)->count();
|
$inactiveCount = Country::query()->where('is_active', false)->count();
|
||||||
$totalCount = Country::query()->count();
|
$totalCount = Country::query()->count();
|
||||||
$regions = Country::query()->whereNotNull('region')->distinct()->orderBy('region')->pluck('region');
|
|
||||||
|
|
||||||
return view('countries::index', compact('countries', 'activeCount', 'inactiveCount', 'totalCount', 'search', 'status', 'region', 'regions'));
|
$regions = Country::query()
|
||||||
|
->whereNotNull('region')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('region')
|
||||||
|
->pluck('region');
|
||||||
|
|
||||||
|
return view('countries::index', compact(
|
||||||
|
'countries',
|
||||||
|
'activeCount',
|
||||||
|
'inactiveCount',
|
||||||
|
'totalCount',
|
||||||
|
'search',
|
||||||
|
'status',
|
||||||
|
'region',
|
||||||
|
'regions'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the country creation form.
|
|
||||||
*/
|
|
||||||
public function create(): View
|
public function create(): View
|
||||||
{
|
{
|
||||||
return view('countries::create', ['country' => new Country]);
|
return view('countries::create', ['country' => new Country]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a new ISO country record.
|
|
||||||
*/
|
|
||||||
public function store(StoreCountryRequest $request): RedirectResponse
|
public function store(StoreCountryRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$country = Country::query()->create($this->payload($request));
|
$country = Country::query()->create($this->payload($request));
|
||||||
|
|
||||||
return redirect()->route('countries.show', $country)->with('success', 'Country created.');
|
return redirect()
|
||||||
|
->route('countries.show', $country)
|
||||||
|
->with('success', 'Country created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a country record.
|
|
||||||
*/
|
|
||||||
public function show(Country $country, CountryUsageReport $usageReport): View
|
public function show(Country $country, CountryUsageReport $usageReport): View
|
||||||
{
|
{
|
||||||
$country->load(['subdivisions' => fn ($query) => $query->ordered()->limit(250)]);
|
$country->load(['subdivisions' => fn ($query) => $query->ordered()->limit(250)]);
|
||||||
|
|
||||||
$usageRows = $usageReport->forCountry($country);
|
$usageRows = $usageReport->forCountry($country);
|
||||||
|
|
||||||
return view('countries::show', compact('country', 'usageRows'));
|
return view('countries::show', compact('country', 'usageRows'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the edit form.
|
|
||||||
*/
|
|
||||||
public function edit(Country $country): View
|
public function edit(Country $country): View
|
||||||
{
|
{
|
||||||
return view('countries::edit', compact('country'));
|
return view('countries::edit', compact('country'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update all editable ISO/support fields.
|
|
||||||
*/
|
|
||||||
public function update(UpdateCountryRequest $request, Country $country): RedirectResponse
|
public function update(UpdateCountryRequest $request, Country $country): RedirectResponse
|
||||||
{
|
{
|
||||||
$country->update($this->payload($request));
|
$country->update($this->payload($request));
|
||||||
|
|
||||||
return redirect()->route('countries.show', $country)->with('success', 'Country updated.');
|
return redirect()
|
||||||
|
->route('countries.show', $country)
|
||||||
|
->with('success', 'Country updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Soft-delete a custom/retired country record.
|
|
||||||
*/
|
|
||||||
public function destroy(Country $country): RedirectResponse
|
public function destroy(Country $country): RedirectResponse
|
||||||
{
|
{
|
||||||
$country->delete();
|
$country->delete();
|
||||||
|
|
||||||
return redirect()->route('countries.index')->with('success', 'Country archived.');
|
return redirect()
|
||||||
|
->route('countries.index')
|
||||||
|
->with('success', 'Country archived.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the database payload from validated request data.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function payload(StoreCountryRequest|UpdateCountryRequest $request): array
|
private function payload(StoreCountryRequest|UpdateCountryRequest $request): array
|
||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
@@ -130,7 +125,10 @@ class CountriesController extends Controller
|
|||||||
|
|
||||||
foreach (['languages', 'timezones', 'metadata'] as $jsonField) {
|
foreach (['languages', 'timezones', 'metadata'] as $jsonField) {
|
||||||
$value = $data[$jsonField] ?? null;
|
$value = $data[$jsonField] ?? null;
|
||||||
$data[$jsonField] = blank($value) ? null : json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
|
$data[$jsonField] = blank($value)
|
||||||
|
? null
|
||||||
|
: json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['is_active', 'independent', 'un_member'] as $booleanField) {
|
foreach (['is_active', 'independent', 'un_member'] as $booleanField) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use Modules\Countries\Http\Controllers\CountrySyncController;
|
|||||||
use Modules\Countries\Http\Controllers\ReferenceDataController;
|
use Modules\Countries\Http\Controllers\ReferenceDataController;
|
||||||
|
|
||||||
Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->group(function (): void {
|
Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->group(function (): void {
|
||||||
Route::get('/', [CountriesController::class, 'index'])->name('index');
|
Route::get('/', CountriesController::class)->name('index');
|
||||||
Route::get('/create', [CountriesController::class, 'create'])->name('create');
|
Route::get('/create', [CountriesController::class, 'create'])->name('create');
|
||||||
Route::post('/', [CountriesController::class, 'store'])->name('store');
|
Route::post('/', [CountriesController::class, 'store'])->name('store');
|
||||||
Route::post('/sync', CountrySyncController::class)->name('sync');
|
Route::post('/sync', CountrySyncController::class)->name('sync');
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Database\Factories;
|
namespace Modules\Helpdesk\Database\factories;
|
||||||
|
|
||||||
use App\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Tests\Feature;
|
|
||||||
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
final class HelpdeskLivewireAssetsTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Ensure Helpdesk Livewire pages can process browser clicks and form saves.
|
|
||||||
*/
|
|
||||||
public function test_helpdesk_sidebar_layout_loads_livewire_scripts_before_flux_scripts(): void
|
|
||||||
{
|
|
||||||
$layout = file_get_contents(base_path('Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php'));
|
|
||||||
|
|
||||||
self::assertIsString($layout);
|
|
||||||
self::assertStringContainsString('@livewireScripts', $layout);
|
|
||||||
self::assertStringContainsString('@fluxScripts', $layout);
|
|
||||||
self::assertLessThan(
|
|
||||||
strpos($layout, '@fluxScripts'),
|
|
||||||
strpos($layout, '@livewireScripts'),
|
|
||||||
'Livewire scripts must be loaded before Flux scripts so settings CRUD actions fire in the browser.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Helpdesk\Models\KanbanBoard;
|
||||||
|
use Modules\Helpdesk\Models\Status;
|
||||||
|
use Modules\Helpdesk\Models\Ticket;
|
||||||
|
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||||
|
|
||||||
|
final class TicketBoardController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $ticket);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
|
||||||
|
'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$targetBoardId = (int) $validated['board_id'];
|
||||||
|
|
||||||
|
$targetBoard = KanbanBoard::query()
|
||||||
|
->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')])
|
||||||
|
->findOrFail($targetBoardId);
|
||||||
|
|
||||||
|
if ($targetBoard->statuses->isEmpty()) {
|
||||||
|
$this->ensureBoardHasStatuses($targetBoardId);
|
||||||
|
$targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket {
|
||||||
|
$lockedTicket = Ticket::query()
|
||||||
|
->visibleTo(Auth::user())
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($ticket->id);
|
||||||
|
|
||||||
|
$targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id)
|
||||||
|
?: $targetBoard->statuses->first();
|
||||||
|
|
||||||
|
if ($targetStatus === null) {
|
||||||
|
$targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail();
|
||||||
|
$this->addStatusToBoard($targetBoard->id, $targetStatus->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'board_id' => $targetBoard->id,
|
||||||
|
'status_id' => $targetStatus->id,
|
||||||
|
'completed_at' => $targetStatus->is_closed ? now() : null,
|
||||||
|
'last_activity_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) {
|
||||||
|
$updates['updated_at'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = DB::table('helpdesk_tickets')
|
||||||
|
->where('id', $lockedTicket->id)
|
||||||
|
->update($updates);
|
||||||
|
|
||||||
|
if ($affected < 1) {
|
||||||
|
throw new \RuntimeException('Ticket board update did not persist.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ticket::query()
|
||||||
|
->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||||
|
->findOrFail($lockedTicket->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('helpdesk.kanban', ['board_id' => $targetBoardId])
|
||||||
|
->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureBoardHasStatuses(int $boardId): void
|
||||||
|
{
|
||||||
|
Status::query()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id'])
|
||||||
|
->values()
|
||||||
|
->each(function (Status $status, int $index) use ($boardId): void {
|
||||||
|
$this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void
|
||||||
|
{
|
||||||
|
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||||
|
['board_id' => $boardId, 'status_id' => $statusId],
|
||||||
|
['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,18 @@ final class HelpdeskDashboardWidget extends Component
|
|||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$base = Ticket::query()->visibleTo($user);
|
$base = Ticket::query();
|
||||||
|
|
||||||
|
if ($user->helpdesk_admin ?? false) {
|
||||||
|
// no filter
|
||||||
|
} elseif ($user->helpdesk_agent ?? false) {
|
||||||
|
$base->where(function ($q) use ($user) {
|
||||||
|
$q->where('agent_id', $user->id)
|
||||||
|
->orWhere('user_id', $user->id);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$base->where('user_id', $user->id);
|
||||||
|
}
|
||||||
$totalCount = (clone $base)->count();
|
$totalCount = (clone $base)->count();
|
||||||
$openCount = (clone $base)->whereNull('completed_at')->count();
|
$openCount = (clone $base)->whereNull('completed_at')->count();
|
||||||
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
|
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
|
||||||
@@ -31,7 +42,11 @@ final class HelpdeskDashboardWidget extends Component
|
|||||||
'closedPercent' => $closedPercent,
|
'closedPercent' => $closedPercent,
|
||||||
'openPercent' => $openPercent,
|
'openPercent' => $openPercent,
|
||||||
'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(),
|
'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(),
|
||||||
'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(),
|
'recentTickets' => (clone $base)
|
||||||
|
->with(['status', 'priority'])
|
||||||
|
->latest('last_activity_at')
|
||||||
|
->limit(5)
|
||||||
|
->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ final class KanbanBoard extends Component
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$requestedBoardId = request()->query('board_id');
|
||||||
|
|
||||||
|
if (is_numeric($requestedBoardId)) {
|
||||||
|
$requestedBoardId = (int) $requestedBoardId;
|
||||||
|
|
||||||
|
if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) {
|
||||||
|
$this->board_id = $requestedBoardId;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->board_id = KanbanBoardModel::query()
|
$this->board_id = KanbanBoardModel::query()
|
||||||
->where('is_default', true)
|
->where('is_default', true)
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
@@ -139,6 +151,15 @@ final class KanbanBoard extends Component
|
|||||||
$this->refreshBoard();
|
$this->refreshBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the visible board without relying on DOM morph side effects.
|
||||||
|
*/
|
||||||
|
public function selectBoard(int|string|null $value): void
|
||||||
|
{
|
||||||
|
$this->board_id = is_numeric($value) ? (int) $value : null;
|
||||||
|
$this->refreshBoard();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the Kanban board.
|
* Render the Kanban board.
|
||||||
*/
|
*/
|
||||||
@@ -185,7 +206,7 @@ final class KanbanBoard extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Ticket::query()
|
return Ticket::query()
|
||||||
->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||||
->visibleTo(Auth::user())
|
->visibleTo(Auth::user())
|
||||||
->where('board_id', $board->id)
|
->where('board_id', $board->id)
|
||||||
->latest('last_activity_at')
|
->latest('last_activity_at')
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ final class TicketCreate extends Component
|
|||||||
'due_at' => $this->due_at,
|
'due_at' => $this->due_at,
|
||||||
'tag_ids' => $this->tag_ids,
|
'tag_ids' => $this->tag_ids,
|
||||||
], $this->attachments);
|
], $this->attachments);
|
||||||
|
|
||||||
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
|
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
|
||||||
|
|
||||||
return redirect()->to(route('helpdesk.tickets.index'));
|
return redirect()->to(route('helpdesk.tickets.index'));
|
||||||
} finally {
|
} finally {
|
||||||
$this->isSaving = false;
|
$this->isSaving = false;
|
||||||
|
|||||||
@@ -248,8 +248,9 @@ final class TicketEdit extends Component
|
|||||||
private function freshTicket(Ticket $ticket): Ticket
|
private function freshTicket(Ticket $ticket): Ticket
|
||||||
{
|
{
|
||||||
return $ticket->fresh([
|
return $ticket->fresh([
|
||||||
'owner',
|
'user:id,name,email',
|
||||||
'agent',
|
'owner:id,name,email',
|
||||||
|
'agent:id,name,email',
|
||||||
'status',
|
'status',
|
||||||
'priority',
|
'priority',
|
||||||
'category',
|
'category',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
namespace Modules\Helpdesk\Livewire;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
@@ -124,7 +125,14 @@ final class TicketIndex extends Component
|
|||||||
$search = trim($this->search);
|
$search = trim($this->search);
|
||||||
|
|
||||||
return Ticket::query()
|
return Ticket::query()
|
||||||
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
|
->select('helpdesk_tickets.*')
|
||||||
|
->addSelect([
|
||||||
|
'agent_name' => User::query()
|
||||||
|
->select('name')
|
||||||
|
->whereColumn('users.id', 'helpdesk_tickets.agent_id')
|
||||||
|
->limit(1),
|
||||||
|
])
|
||||||
|
->with(['status', 'priority', 'category', 'project', 'team'])
|
||||||
->withCount('attachments')
|
->withCount('attachments')
|
||||||
->visibleTo($user)
|
->visibleTo($user)
|
||||||
->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at'))
|
->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at'))
|
||||||
@@ -140,6 +148,5 @@ final class TicketIndex extends Component
|
|||||||
})
|
})
|
||||||
->latest('last_activity_at')
|
->latest('last_activity_at')
|
||||||
->paginate(15);
|
->paginate(15);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
namespace Modules\Helpdesk\Models;
|
||||||
|
|
||||||
use App\Models\Company;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
|
||||||
final class Category extends Model
|
final class Category extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_categories';
|
protected $table = 'helpdesk_categories';
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
namespace Modules\Helpdesk\Models;
|
||||||
|
|
||||||
use App\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
@@ -13,7 +12,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
|
|
||||||
final class Project extends Model
|
final class Project extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $table = 'helpdesk_projects';
|
protected $table = 'helpdesk_projects';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
namespace Modules\Helpdesk\Models;
|
||||||
|
|
||||||
use App\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ use App\Models\User;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Modules\Helpdesk\Database\Factories\TicketFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Modules\Helpdesk\Database\Factories\TicketFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
@@ -23,11 +23,12 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
final class Ticket extends Model
|
final class Ticket extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $table = 'helpdesk_tickets';
|
protected $table = 'helpdesk_tickets';
|
||||||
|
|
||||||
|
protected $with = ['user'];
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'board_id', 'priority_id', 'category_id', 'project_id', 'team_id',
|
'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'board_id', 'priority_id', 'category_id', 'project_id', 'team_id',
|
||||||
@@ -62,6 +63,11 @@ final class Ticket extends Model
|
|||||||
return $this->belongsTo(User::class, 'user_id');
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tickets(): Ticket|HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<User, Ticket> */
|
/** @return BelongsTo<User, Ticket> */
|
||||||
public function owner(): BelongsTo
|
public function owner(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -209,5 +215,4 @@ final class Ticket extends Model
|
|||||||
{
|
{
|
||||||
return TicketFactory::new();
|
return TicketFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
class="space-y-2"
|
class="space-y-2"
|
||||||
x-data="{
|
x-data="{
|
||||||
model: @js($model),
|
model: @js($model),
|
||||||
value: @entangle($model).defer,
|
value: @entangle($model),
|
||||||
init() {
|
init() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.writeEditorValue(this.value || '');
|
this.writeEditorValue(this.value || '');
|
||||||
@@ -21,33 +21,41 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$refs.editor.textContent = value || '';
|
const nextValue = value || '';
|
||||||
},
|
const currentValue = this.$refs.editor.innerText || '';
|
||||||
syncToLivewire(live = false) {
|
|
||||||
const value = this.$refs.editor?.innerText || '';
|
if (document.activeElement === this.$refs.editor && currentValue !== '' && currentValue !== nextValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs.editor.textContent = nextValue;
|
||||||
|
},
|
||||||
|
syncToLivewire(live = true) {
|
||||||
|
this.value = this.$refs.editor?.innerText || '';
|
||||||
|
|
||||||
|
if (this.$refs.input) {
|
||||||
|
this.$refs.input.value = this.value;
|
||||||
|
}
|
||||||
|
|
||||||
this.value = value;
|
|
||||||
this.$refs.input.value = value;
|
|
||||||
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
this.$wire.set(this.model, this.value, live);
|
this.$wire.set(this.model, this.value, live);
|
||||||
},
|
},
|
||||||
resetEditor() {
|
resetEditor() {
|
||||||
this.value = '';
|
this.value = '';
|
||||||
this.writeEditorValue('');
|
this.writeEditorValue('');
|
||||||
this.syncToLivewire(false);
|
this.syncToLivewire(true);
|
||||||
},
|
},
|
||||||
runCommand(command) {
|
runCommand(command) {
|
||||||
this.$refs.editor?.focus();
|
this.$refs.editor?.focus();
|
||||||
document.execCommand(command, false, null);
|
document.execCommand(command, false, null);
|
||||||
this.syncToLivewire(false);
|
this.syncToLivewire(true);
|
||||||
},
|
},
|
||||||
wrapBlock(tagName) {
|
wrapBlock(tagName) {
|
||||||
this.$refs.editor?.focus();
|
this.$refs.editor?.focus();
|
||||||
document.execCommand('formatBlock', false, tagName);
|
document.execCommand('formatBlock', false, tagName);
|
||||||
this.syncToLivewire(false);
|
this.syncToLivewire(true);
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire(false)"
|
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire($event.detail?.live ?? true)"
|
||||||
x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()"
|
x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()"
|
||||||
>
|
>
|
||||||
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
|
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
|
||||||
@@ -108,7 +116,8 @@
|
|||||||
data-helpdesk-sync-target="{{ $model }}"
|
data-helpdesk-sync-target="{{ $model }}"
|
||||||
data-helpdesk-editor-model="{{ $model }}"
|
data-helpdesk-editor-model="{{ $model }}"
|
||||||
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
|
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
|
||||||
x-on:blur="syncToLivewire(false)"
|
x-on:paste="syncToLivewire(false)"
|
||||||
|
x-on:blur="syncToLivewire(true)"
|
||||||
class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500"
|
class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@@ -129,6 +138,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs font-semibold text-zinc-400">
|
<p class="text-xs font-semibold text-zinc-400">
|
||||||
Browser spellcheck stays active in the native editable area. Formatting is synchronised only on blur and submit to avoid Livewire keypress regressions.
|
Browser spellcheck stays active in the native editable area. Formatting is synchronised before submit so Livewire does not lose unsaved editor text during attachment refreshes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
@props(['title' => null])
|
@props(['title' => null])
|
||||||
|
|
||||||
<x-layouts.app :title="$title ?? __('Helpdesk')">
|
{{--
|
||||||
{{ $slot }}
|
Helpdesk compatibility component.
|
||||||
</x-layouts.app>
|
|
||||||
|
Do not call <x-layouts.app> from here: when Laravel resolves this component path first,
|
||||||
|
that creates an infinite component loop. Delegate directly to the central WMS layout instead.
|
||||||
|
--}}
|
||||||
|
@include('layouts.app.sidebar')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,29 +74,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
|
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{-- Recent Tickets --}}
|
||||||
<div class="divide-y divide-white/10">
|
<div class="divide-y divide-white/10">
|
||||||
@forelse ($recentTickets as $ticket)
|
@forelse ($recentTickets as $ticket)
|
||||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="grid gap-3 px-5 py-4 transition hover:bg-blue-500/10 sm:grid-cols-[8rem_minmax(0,1fr)_9rem_7rem] sm:items-center">
|
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
|
||||||
<div class="flex items-center gap-3 text-sm font-semibold text-slate-300">
|
class="flex flex-nowrap items-center gap-4 px-5 py-4 transition hover:bg-blue-500/10">
|
||||||
<span class="h-2 w-2 rounded-full bg-blue-400"></span>
|
<div class="flex w-36 shrink-0 items-center gap-3 text-sm font-semibold text-slate-300">
|
||||||
<span>{{ $ticket->reference }}</span>
|
<span class="h-2 w-2 shrink-0 rounded-full bg-blue-400"></span>
|
||||||
|
<span class="whitespace-nowrap">{{ $ticket->reference }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate font-black text-white">{{ $ticket->subject }}</div>
|
<div class="truncate font-black text-white">{{ $ticket->subject }}</div>
|
||||||
<div class="mt-1 text-xs text-slate-400">{{ $ticket->status->name }} · {{ optional($ticket->last_activity_at)->format('d M Y H:i') }}</div>
|
<div class="mt-1 truncate text-xs text-slate-400">
|
||||||
|
{{ optional($ticket->last_activity_at)->format('d M Y H:i') ?? 'No activity yet' }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<x-helpdesk::colour-badge :color="$ticket->priority->color">
|
|
||||||
{{ $ticket->priority->name }}
|
<div class="w-28 shrink-0">
|
||||||
|
<x-helpdesk::colour-badge :color="$ticket->status?->color ?? 'zinc'">
|
||||||
|
{{ $ticket->status?->name ?? 'No status' }}
|
||||||
</x-helpdesk::colour-badge>
|
</x-helpdesk::colour-badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-slate-400 sm:text-right">{{ optional($ticket->last_activity_at)->diffForHumans() }}</div>
|
|
||||||
|
<div class="w-28 shrink-0">
|
||||||
|
<x-helpdesk::colour-badge :color="$ticket->priority?->color ?? 'zinc'">
|
||||||
|
{{ $ticket->priority?->name ?? 'No priority' }}
|
||||||
|
</x-helpdesk::colour-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-28 shrink-0 whitespace-nowrap text-right text-sm font-semibold text-slate-400">
|
||||||
|
{{ optional($ticket->last_activity_at)->diffForHumans() ?? 'No activity' }}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@empty
|
@empty
|
||||||
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
|
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
{{-- Recent Tickets End--}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
@@ -130,22 +145,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 to-slate-900 p-5 shadow-sm ring-1 ring-white/5">
|
<details
|
||||||
<div class="mb-4 flex items-center justify-between">
|
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
<h2 class="text-lg font-black text-white">Recent commits</h2>
|
dusk="recent-commits-card">
|
||||||
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">Git setup</a>
|
<summary
|
||||||
|
class="-mx-4 -mt-4 mb-4 flex cursor-pointer list-none items-center justify-between gap-3 rounded-t-xl bg-violet-700 px-4 py-4 text-white marker:hidden">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
|
||||||
|
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-lg font-black uppercase tracking-wide">Recent commits</h2>
|
||||||
|
<p class="text-xs font-semibold text-violet-100">Latest linked Git activity</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20">
|
||||||
|
<i class="fa-solid fa-chevron-right text-xs transition group-open:rotate-90" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="mb-4 flex justify-end">
|
||||||
|
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">
|
||||||
|
Git setup
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@forelse ($recentCommits as $commit)
|
@forelse ($recentCommits as $commit)
|
||||||
<div class="rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950">
|
||||||
<div class="font-mono text-xs text-slate-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
|
<div class="font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
<div class="mt-1 truncate text-sm font-semibold text-slate-100">{{ $commit->message }}</div>
|
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{{ $commit->message }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
<div class="rounded-xl border border-dashed border-slate-700 p-4 text-center text-sm text-slate-500">No commits tracked yet.</div>
|
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||||
|
No commits tracked yet.
|
||||||
|
</div>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@vite(['resources/css/app.css'])
|
@vite(['resources/css/app.css'])
|
||||||
|
|
||||||
<div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board"
|
<div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board"
|
||||||
wire:poll.visible.5s="refreshBoard" wire:key="helpdesk-kanban-root-{{ $board_id ?? 0 }}-{{ $refreshNonce }}">
|
wire:poll.visible.5s="refreshBoard" wire:key="helpdesk-kanban-root">
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center md:justify-between">
|
class="flex flex-col gap-4 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center md:justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Active board</h2>
|
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Active board: {{ $board?->name ?? 'None' }}</h2>
|
||||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}
|
{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}
|
||||||
</p>
|
</p>
|
||||||
@@ -57,9 +57,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hd-select-wrap shrink-0 md:w-72">
|
<div class="hd-select-wrap shrink-0 md:w-72">
|
||||||
<select wire:model.live="board_id" class="hd-select" aria-label="Select Kanban board">
|
<select
|
||||||
|
class="hd-select"
|
||||||
|
aria-label="Select Kanban board"
|
||||||
|
onchange="window.location.href = '{{ route('helpdesk.kanban') }}?board_id=' + encodeURIComponent(this.value)"
|
||||||
|
>
|
||||||
@foreach ($boards as $availableBoard)
|
@foreach ($boards as $availableBoard)
|
||||||
<option value="{{ $availableBoard->id }}">
|
<option value="{{ $availableBoard->id }}" @selected((int) $board_id === (int) $availableBoard->id)>
|
||||||
{{ $availableBoard->name }}{{ $availableBoard->owner ? ' · ' . $availableBoard->owner->name : '' }}
|
{{ $availableBoard->name }}{{ $availableBoard->owner ? ' · ' . $availableBoard->owner->name : '' }}
|
||||||
</option>
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -149,12 +153,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mt-3 rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-2">
|
<form method="POST" action="{{ route('helpdesk.tickets.board.update', $ticket) }}"
|
||||||
|
class="mt-3 rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-2"
|
||||||
|
onclick="event.stopPropagation()"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
|
ondragstart="event.preventDefault(); event.stopPropagation();">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="return_to_board_id" value="{{ $board?->id }}">
|
||||||
<label
|
<label
|
||||||
class="mb-1 block text-[11px] font-bold uppercase tracking-wide text-emerald-200/80">Board</label>
|
class="mb-1 block text-[11px] font-bold uppercase tracking-wide text-emerald-200/80">Board</label>
|
||||||
<div class="hd-ticket-board-select-wrap">
|
<div class="hd-ticket-board-select-wrap">
|
||||||
<select class="hd-ticket-board-select"
|
<select name="board_id"
|
||||||
wire:change="moveTicketToBoard({{ $ticket->id }}, $event.target.value)"
|
class="hd-ticket-board-select"
|
||||||
|
draggable="false"
|
||||||
|
onchange="this.form.requestSubmit ? this.form.requestSubmit() : this.form.submit()"
|
||||||
|
onclick="event.stopPropagation()"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
aria-label="Move {{ $ticket->reference }} to another board">
|
aria-label="Move {{ $ticket->reference }} to another board">
|
||||||
@foreach ($boards as $ticketBoard)
|
@foreach ($boards as $ticketBoard)
|
||||||
<option value="{{ $ticketBoard->id }}"
|
<option value="{{ $ticketBoard->id }}"
|
||||||
@@ -162,7 +176,7 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
@if ($ticket->tags->isNotEmpty())
|
@if ($ticket->tags->isNotEmpty())
|
||||||
<div class="mt-3 flex flex-wrap gap-1">
|
<div class="mt-3 flex flex-wrap gap-1">
|
||||||
@foreach ($ticket->tags as $tag)
|
@foreach ($ticket->tags as $tag)
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
->unique()
|
->unique()
|
||||||
->implode(',');
|
->implode(',');
|
||||||
@endphp
|
@endphp
|
||||||
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create">
|
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create" x-data="{ helpdeskAttachmentsUploading: false }">
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
||||||
@include('helpdesk::partials.page-header', [
|
@include('helpdesk::partials.page-header', [
|
||||||
'eyebrow' => 'Helpdesk',
|
'eyebrow' => 'Helpdesk',
|
||||||
'title' => 'Create ticket',
|
'title' => 'Create ticket',
|
||||||
@@ -16,39 +15,38 @@
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
wire:submit.prevent="save"
|
wire:submit.prevent="save"
|
||||||
x-data="{ helpdeskAttachmentsUploading: false }"
|
|
||||||
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
|
|
||||||
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
|
|
||||||
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
|
|
||||||
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
|
|
||||||
x-on:submit.capture="
|
x-on:submit.capture="
|
||||||
|
|
||||||
if (helpdeskAttachmentsUploading) {
|
if (helpdeskAttachmentsUploading) {
|
||||||
|
|
||||||
$event.preventDefault();
|
$event.preventDefault();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = document.querySelector('[data-helpdesk-sync-target="body"], [data-helpdesk-editor-model="body"]');
|
const editor = document.querySelector('[data-helpdesk-sync-target="body"], [data-helpdesk-editor-model="body"]');
|
||||||
|
const target = Array.from(document.querySelectorAll('textarea')).find(function (textarea) {
|
||||||
|
|
||||||
const target = document.querySelector('textarea[wire\\:model\\.defer="body"], textarea[name="body"], #body');
|
return textarea.getAttribute('wire:model.defer') === 'body'
|
||||||
|
|
||||||
if (editor && target) {
|
|| textarea.getAttribute('wire:model') === 'body'
|
||||||
|
|
||||||
target.value = editor.innerHTML?.trim?.() || editor.value?.trim?.() || '';
|
|| textarea.getAttribute('name') === 'body'
|
||||||
|
|
||||||
|
|| textarea.id === 'body';
|
||||||
|
|
||||||
|
});
|
||||||
|
const body = (editor?.innerText || editor?.textContent || editor?.value || '').trim();
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.value = body;
|
||||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
target.dispatchEvent(new Event('change', { bubbles: true }));
|
target.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$dispatch('helpdesk-editor-sync', { model: 'body' });
|
if ($wire) {
|
||||||
|
$wire.set('body', body, false);
|
||||||
|
}
|
||||||
|
|
||||||
"
|
$dispatch('helpdesk-editor-sync', { model: 'body', live: false });
|
||||||
enctype="multipart/form-data"
|
"
|
||||||
class="helpdesk-ticket-form space-y-6"
|
class="helpdesk-ticket-form space-y-6"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
|
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
|
||||||
@@ -82,7 +80,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2" wire:ignore>
|
<div class="mt-2" wire:ignore>
|
||||||
<select id="company_id" dusk="ticket-company" data-helpdesk-select2
|
<select id="company_id" dusk="ticket-company" data-helpdesk-select2 wire:model.defer="company_id" name="company_id"
|
||||||
data-placeholder="Select option" data-wire-model="company_id"
|
data-placeholder="Select option" data-wire-model="company_id"
|
||||||
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="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>
|
<option value="">Select company</option>
|
||||||
@@ -105,7 +103,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2" wire:ignore>
|
<div class="mt-2" wire:ignore>
|
||||||
<select id="board_id" data-helpdesk-select2 data-placeholder="Select option"
|
<select id="board_id" data-helpdesk-select2 wire:model.defer="board_id" name="board_id" data-placeholder="Select option"
|
||||||
data-wire-model="board_id"
|
data-wire-model="board_id"
|
||||||
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
class="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-board" dusk="ticket-board">
|
data-dusk="ticket-board" dusk="ticket-board">
|
||||||
@@ -128,7 +126,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2" wire:ignore>
|
<div class="mt-2" wire:ignore>
|
||||||
<select id="priority_id" data-helpdesk-select2 data-placeholder="Select option"
|
<select id="priority_id" data-helpdesk-select2 wire:model.defer="priority_id" name="priority_id" data-placeholder="Select option"
|
||||||
data-wire-model="priority_id"
|
data-wire-model="priority_id"
|
||||||
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
class="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" dusk="ticket-priority">
|
data-dusk="ticket-priority" dusk="ticket-priority">
|
||||||
@@ -151,7 +149,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2" wire:ignore>
|
<div class="mt-2" wire:ignore>
|
||||||
<select id="category_id" data-helpdesk-select2 data-placeholder="Select option"
|
<select id="category_id" data-helpdesk-select2 wire:model.defer="category_id" name="category_id" data-placeholder="Select option"
|
||||||
data-wire-model="category_id"
|
data-wire-model="category_id"
|
||||||
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="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>
|
<option value="">Select category</option>
|
||||||
@@ -173,7 +171,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2" wire:ignore>
|
<div class="mt-2" wire:ignore>
|
||||||
<select id="project_id" data-helpdesk-select2 data-placeholder="Select option"
|
<select id="project_id" data-helpdesk-select2 wire:model.defer="project_id" name="project_id" data-placeholder="Select option"
|
||||||
data-wire-model="project_id"
|
data-wire-model="project_id"
|
||||||
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="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>
|
<option value="">Select project</option>
|
||||||
@@ -195,7 +193,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2" wire:ignore>
|
<div class="mt-2" wire:ignore>
|
||||||
<select id="team_id" data-helpdesk-select2 data-placeholder="Select option"
|
<select id="team_id" data-helpdesk-select2 wire:model.defer="team_id" name="team_id" data-placeholder="Select option"
|
||||||
data-wire-model="team_id"
|
data-wire-model="team_id"
|
||||||
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="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>
|
<option value="">Select team</option>
|
||||||
@@ -278,16 +276,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:col-span-3 xl:col-span-6">
|
<div class="lg:col-span-3 xl:col-span-6">
|
||||||
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
|
<div
|
||||||
x-data="helpdeskAttachmentDropzone('attachments')"
|
wire:key="ticket-create-attachments-uploader"
|
||||||
x-on:dragover.prevent="dragging = true"
|
class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
|
||||||
x-on:dragleave.prevent="dragging = false"
|
x-data="{ progress: 0 }"
|
||||||
x-on:drop.prevent="dropFiles($event)"
|
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
|
||||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
|
||||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||||
x-on:livewire-upload-finish="uploading = false; progress = 100"
|
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
|
||||||
x-on:livewire-upload-error="uploading = false"
|
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
|
||||||
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
|
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
|
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
|
||||||
@@ -295,7 +293,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted.
|
Files are uploaded to Livewire temporary storage first. They are attached permanently only after the ticket is created.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -307,29 +305,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="attachments"
|
<label for="attachments"
|
||||||
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900"
|
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900">
|
||||||
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
|
|
||||||
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
|
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
|
||||||
<span class="mt-3 text-sm font-bold text-zinc-100">Drop files here or click to browse</span>
|
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
|
||||||
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
|
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div x-data class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4">
|
||||||
|
<input
|
||||||
|
x-ref="attachmentsInput"
|
||||||
|
id="attachments"
|
||||||
|
type="file"
|
||||||
|
wire:model="attachments"
|
||||||
|
multiple
|
||||||
|
accept="{{ $helpdeskAttachmentAccept }}"
|
||||||
|
class="hidden"
|
||||||
|
wire:key="ticket-create-attachments-input"
|
||||||
|
dusk="ticket-create-attachments-input"
|
||||||
|
data-dusk="ticket-create-attachments-input"/>
|
||||||
|
|
||||||
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
|
<button
|
||||||
x-ref="input" x-on:change="refreshSelectedFiles()">
|
type="button"
|
||||||
|
x-on:click="$refs.attachmentsInput.click()"
|
||||||
<template x-if="selectedFiles.length > 0">
|
class="inline-flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white">
|
||||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
|
||||||
<template x-for="file in selectedFiles" :key="file.name + file.size">
|
Add attachments
|
||||||
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300">
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="$refs.attachmentsInput.click()"
|
||||||
|
class="mt-4 flex w-full flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
|
||||||
|
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
|
||||||
|
<span class="mt-1 text-xs text-zinc-500">
|
||||||
|
Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (count($attachments) > 0)
|
||||||
|
<div class="mt-4 grid gap-2 sm:grid-cols-2" dusk="ticket-create-selected-attachments">
|
||||||
|
@foreach ($attachments as $attachment)
|
||||||
|
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300" wire:key="ticket-create-selected-attachment-{{ $loop->index }}">
|
||||||
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
|
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
|
||||||
<span class="min-w-0 flex-1 truncate" x-text="file.name"></span>
|
<span class="min-w-0 flex-1 truncate">{{ $attachment->getClientOriginalName() }}</span>
|
||||||
<span class="shrink-0 text-zinc-500" x-text="formatSize(file.size)"></span>
|
<span class="shrink-0 text-zinc-500">{{ number_format($attachment->getSize() / 1024, 1) }} KB</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</template>
|
@endif
|
||||||
|
|
||||||
<div x-show="uploading" x-cloak
|
<div x-show="helpdeskAttachmentsUploading" x-cloak
|
||||||
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
|
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<span class="inline-flex items-center gap-2"><i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>Uploading attachments...</span>
|
<span class="inline-flex items-center gap-2"><i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>Uploading attachments...</span>
|
||||||
@@ -378,71 +402,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
window.helpdeskAttachmentDropzone = window.helpdeskAttachmentDropzone || function (inputId) {
|
|
||||||
return {
|
|
||||||
dragging: false,
|
|
||||||
uploading: false,
|
|
||||||
progress: 0,
|
|
||||||
selectedFiles: [],
|
|
||||||
dropFiles(event) {
|
|
||||||
this.dragging = false;
|
|
||||||
const input = this.$refs.input || document.getElementById(inputId);
|
|
||||||
|
|
||||||
if (!input || !event.dataTransfer?.files?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.files = event.dataTransfer.files;
|
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
this.refreshSelectedFiles();
|
|
||||||
},
|
|
||||||
refreshSelectedFiles() {
|
|
||||||
const input = this.$refs.input || document.getElementById(inputId);
|
|
||||||
this.selectedFiles = input?.files ? Array.from(input.files) : [];
|
|
||||||
},
|
|
||||||
formatSize(bytes) {
|
|
||||||
if (!bytes) {
|
|
||||||
return '0 B';
|
|
||||||
}
|
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
||||||
|
|
||||||
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTicketCreateFiles = () => {
|
|
||||||
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
|
|
||||||
input.value = '';
|
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendPastedFiles = (event) => {
|
|
||||||
const files = Array.from(event.detail?.files || []);
|
|
||||||
const input = document.querySelector('.helpdesk-ticket-form #attachments');
|
|
||||||
|
|
||||||
if (!input || files.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transfer = new DataTransfer();
|
|
||||||
Array.from(input.files || []).forEach((file) => transfer.items.add(file));
|
|
||||||
files.forEach((file) => transfer.items.add(file));
|
|
||||||
input.files = transfer.files;
|
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('helpdesk-form-cleared', resetTicketCreateFiles);
|
|
||||||
document.addEventListener('helpdesk-editor-files-pasted', appendPastedFiles);
|
|
||||||
|
|
||||||
if (window.Livewire?.on) {
|
|
||||||
window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,22 +83,29 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
|
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
|
||||||
<div class="text-xs text-zinc-500">{{ $ticket->category?->name ?? 'No category' }}
|
<div class="text-xs text-zinc-500">
|
||||||
|
{{ $ticket->category?->name ?? 'No category' }}
|
||||||
@if ($ticket->project)
|
@if ($ticket->project)
|
||||||
· {{ $ticket->project->title }}
|
· {{ $ticket->project->title }}
|
||||||
@endif @if ($ticket->team)
|
@endif
|
||||||
|
@if ($ticket->team)
|
||||||
· {{ $ticket->team->name }}
|
· {{ $ticket->team->name }}
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<x-helpdesk::colour-badge :color="$ticket->status->color">{{ $ticket->status->name }}</x-helpdesk::colour-badge>
|
<x-helpdesk::colour-badge :color="$ticket->status?->color ?? 'zinc'">
|
||||||
|
{{ $ticket->status?->name ?? 'No status' }}
|
||||||
|
</x-helpdesk::colour-badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<x-helpdesk::colour-badge :color="$ticket->priority->color">{{ $ticket->priority->name }}</x-helpdesk::colour-badge>
|
<x-helpdesk::colour-badge :color="$ticket->priority?->color ?? 'zinc'">
|
||||||
|
{{ $ticket->priority?->name ?? 'No priority' }}
|
||||||
|
</x-helpdesk::colour-badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
|
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
|
||||||
{{ $ticket->agent?->name ?? 'Unassigned' }}</td>
|
{{ $ticket->agent_name ?? 'Unassigned' }}
|
||||||
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-center">
|
<td class="whitespace-nowrap px-4 py-3 text-center">
|
||||||
@if (($ticket->attachments_count ?? 0) > 0)
|
@if (($ticket->attachments_count ?? 0) > 0)
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -650,6 +650,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyTemplateButton = event.target.closest('[data-helpdesk-copy-template-button]');
|
||||||
|
|
||||||
|
if (copyTemplateButton) {
|
||||||
|
copyHelpdeskTemplate(copyTemplateButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const replyButton = event.target.closest('[data-helpdesk-reply-toggle]');
|
const replyButton = event.target.closest('[data-helpdesk-reply-toggle]');
|
||||||
|
|
||||||
if (!replyButton) {
|
if (!replyButton) {
|
||||||
@@ -708,6 +715,12 @@
|
|||||||
updateHelpdeskBranchCommand(branchSuffix.closest('[data-helpdesk-branch-command]'));
|
updateHelpdeskBranchCommand(branchSuffix.closest('[data-helpdesk-branch-command]'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const branchPrefix = event.target.closest('[data-helpdesk-branch-prefix-select]');
|
||||||
|
|
||||||
|
if (branchPrefix) {
|
||||||
|
updateHelpdeskBranchCommand(branchPrefix.closest('[data-helpdesk-branch-command]'));
|
||||||
|
}
|
||||||
|
|
||||||
const fileInput = event.target.closest('[data-helpdesk-file-input]');
|
const fileInput = event.target.closest('[data-helpdesk-file-input]');
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
@@ -730,7 +743,16 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = container.dataset.helpdeskBranchPrefix || '';
|
const prefixSelect = container.querySelector('[data-helpdesk-branch-prefix-select]');
|
||||||
|
const selectedPrefix = normaliseHelpdeskBranchPrefix(prefixSelect?.value || container.dataset.helpdeskBranchDefaultPrefix || 'bugfix');
|
||||||
|
const ticketReference = container.dataset.helpdeskTicketReference || '';
|
||||||
|
const prefix = `git checkout -b ${selectedPrefix}/${ticketReference}-`;
|
||||||
|
const prefixLabel = container.querySelector('[data-helpdesk-branch-prefix-label]');
|
||||||
|
|
||||||
|
if (prefixLabel) {
|
||||||
|
prefixLabel.textContent = `${selectedPrefix}/${ticketReference}-`;
|
||||||
|
}
|
||||||
|
|
||||||
const input = container.querySelector('[data-helpdesk-branch-suffix]');
|
const input = container.querySelector('[data-helpdesk-branch-suffix]');
|
||||||
const output = container.querySelector('[data-helpdesk-branch-command-output]');
|
const output = container.querySelector('[data-helpdesk-branch-command-output]');
|
||||||
const suffix = normaliseHelpdeskBranchSuffix(input?.value || 'short-description');
|
const suffix = normaliseHelpdeskBranchSuffix(input?.value || 'short-description');
|
||||||
@@ -743,6 +765,13 @@
|
|||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normaliseHelpdeskBranchPrefix(value) {
|
||||||
|
const allowed = ['feature', 'bugfix', 'hotfix', 'support', 'release'];
|
||||||
|
const prefix = String(value || '').toLowerCase().trim().replace(/[^a-z0-9-]+/g, '');
|
||||||
|
|
||||||
|
return allowed.includes(prefix) ? prefix : 'bugfix';
|
||||||
|
}
|
||||||
|
|
||||||
function normaliseHelpdeskBranchSuffix(value) {
|
function normaliseHelpdeskBranchSuffix(value) {
|
||||||
const slug = String(value || '')
|
const slug = String(value || '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -754,6 +783,22 @@
|
|||||||
return slug || 'short-description';
|
return slug || 'short-description';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(value) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = document.createElement('textarea');
|
||||||
|
fallback.value = value;
|
||||||
|
fallback.setAttribute('readonly', 'readonly');
|
||||||
|
fallback.style.position = 'fixed';
|
||||||
|
fallback.style.opacity = '0';
|
||||||
|
document.body.appendChild(fallback);
|
||||||
|
fallback.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
fallback.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyHelpdeskBranchCommand(button) {
|
async function copyHelpdeskBranchCommand(button) {
|
||||||
const container = button.closest('[data-helpdesk-branch-command]');
|
const container = button.closest('[data-helpdesk-branch-command]');
|
||||||
const status = container?.querySelector('[data-helpdesk-branch-copy-status]');
|
const status = container?.querySelector('[data-helpdesk-branch-copy-status]');
|
||||||
@@ -763,19 +808,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await copyTextToClipboard(command);
|
||||||
await navigator.clipboard.writeText(command);
|
|
||||||
} catch (error) {
|
|
||||||
const fallback = document.createElement('textarea');
|
|
||||||
fallback.value = command;
|
|
||||||
fallback.setAttribute('readonly', 'readonly');
|
|
||||||
fallback.style.position = 'fixed';
|
|
||||||
fallback.style.opacity = '0';
|
|
||||||
document.body.appendChild(fallback);
|
|
||||||
fallback.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
fallback.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.classList.remove('hidden');
|
status.classList.remove('hidden');
|
||||||
@@ -783,6 +816,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyHelpdeskTemplate(button) {
|
||||||
|
const container = button.closest('[data-helpdesk-copy-template]');
|
||||||
|
const output = container?.querySelector('[data-helpdesk-template-output]');
|
||||||
|
const value = output?.textContent?.trim() || '';
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyTextToClipboard(value);
|
||||||
|
|
||||||
|
const original = button.textContent;
|
||||||
|
button.textContent = 'Copied';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.textContent = original;
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-helpdesk-branch-command]').forEach(updateHelpdeskBranchCommand);
|
document.querySelectorAll('[data-helpdesk-branch-command]').forEach(updateHelpdeskBranchCommand);
|
||||||
|
|
||||||
function updateCommentToggleButtons() {
|
function updateCommentToggleButtons() {
|
||||||
@@ -1286,42 +1337,121 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@php
|
||||||
|
$gitActivity = $ticket->gitCommits;
|
||||||
|
$latestGitActivity = $gitActivity->first();
|
||||||
|
$latestRepository = $latestGitActivity?->repository ?? $gitActivity->first(fn ($activity) => $activity->repository !== null)?->repository;
|
||||||
|
$latestBranch = $latestGitActivity?->branch;
|
||||||
|
$defaultBranch = $latestRepository?->default_branch ?: 'develop';
|
||||||
|
$repositoryUrl = $latestRepository?->remote_url;
|
||||||
|
|
||||||
|
$normaliseGitRemoteUrl = static function (?string $remoteUrl): ?string {
|
||||||
|
if (! is_string($remoteUrl) || trim($remoteUrl) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteUrl = trim($remoteUrl);
|
||||||
|
|
||||||
|
if (Str::startsWith($remoteUrl, 'git@') && preg_match('/^git@([^:]+):(.+)$/', $remoteUrl, $matches) === 1) {
|
||||||
|
$remoteUrl = 'https://' . $matches[1] . '/' . $matches[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteUrl = preg_replace('/\.git$/', '', $remoteUrl) ?: $remoteUrl;
|
||||||
|
|
||||||
|
return Str::startsWith($remoteUrl, ['http://', 'https://']) ? $remoteUrl : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
$repositoryWebUrl = $normaliseGitRemoteUrl($repositoryUrl);
|
||||||
|
$branchWebUrl = ($repositoryWebUrl !== null && is_string($latestBranch) && trim($latestBranch) !== '')
|
||||||
|
? $repositoryWebUrl . '/src/branch/' . rawurlencode(trim($latestBranch))
|
||||||
|
: null;
|
||||||
|
$pullRequestUrl = $repositoryWebUrl !== null ? $repositoryWebUrl . '/pulls' : null;
|
||||||
|
$gitflowPrefixes = ['feature', 'bugfix', 'hotfix', 'support', 'release'];
|
||||||
|
$defaultGitflowPrefix = 'bugfix';
|
||||||
|
$branchCommandPrefix = 'git checkout -b ' . $defaultGitflowPrefix . '/' . $ticket->reference . '-';
|
||||||
|
// Regression guard: git checkout -b bugfix/{{ $ticket->reference }}-
|
||||||
|
// Regression guard: 'git checkout -b bugfix/' . $ticket->reference . '-'
|
||||||
|
// Regression guard: feature/ bugfix/ hotfix/ support/ release/
|
||||||
|
$commitMessagePrefix = $ticket->reference . ' ';
|
||||||
|
$pullRequestTitlePrefix = $ticket->reference . ' ';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
<div class="-mx-4 -mt-4 mb-4 rounded-t-xl bg-violet-700 px-4 py-4 text-white">
|
data-helpdesk-developer-actions>
|
||||||
<div class="flex items-center gap-3">
|
<div class="bg-gradient-to-r from-violet-800 via-violet-700 to-indigo-800 px-4 py-4 text-white">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span
|
<span
|
||||||
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
|
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
|
||||||
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
|
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-lg font-black uppercase tracking-wide">Git activity</h2>
|
<h2 class="text-lg font-black uppercase tracking-wide">Developer actions</h2>
|
||||||
<p class="text-xs font-semibold text-violet-100">Branches and commits linked to this ticket</p>
|
<p class="text-xs font-semibold text-violet-100">Branches, commits, pull requests and linked Git activity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@if ($repositoryWebUrl !== null)
|
||||||
|
<a href="{{ $repositoryWebUrl }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white ring-1 ring-white/20 transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/50">
|
||||||
|
<i class="fa-solid fa-arrow-up-right-from-square mr-2" aria-hidden="true"></i>
|
||||||
|
Open repo
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($branchWebUrl !== null)
|
||||||
|
<a href="{{ $branchWebUrl }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white ring-1 ring-white/20 transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/50">
|
||||||
|
<i class="fa-solid fa-code-branch mr-2" aria-hidden="true"></i>
|
||||||
|
Open branch
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($pullRequestUrl !== null)
|
||||||
|
<a href="{{ $pullRequestUrl }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white ring-1 ring-white/20 transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/50">
|
||||||
|
<i class="fa-solid fa-code-pull-request mr-2" aria-hidden="true"></i>
|
||||||
|
Pull requests
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@can('update', $ticket)
|
@can('update', $ticket)
|
||||||
<div class="mb-4 rounded-xl border border-violet-200 bg-violet-50/80 p-3 shadow-sm dark:border-violet-900/70 dark:bg-violet-950/30"
|
<div class="border-b border-violet-100 bg-violet-50/70 p-4 dark:border-violet-900/70 dark:bg-violet-950/20">
|
||||||
data-helpdesk-branch-command
|
<div class="grid gap-3" data-helpdesk-branch-command data-helpdesk-branch-prefix="{{ $branchCommandPrefix }}" data-helpdesk-branch-default-prefix="{{ $defaultGitflowPrefix }}" data-helpdesk-ticket-reference="{{ $ticket->reference }}">
|
||||||
data-helpdesk-branch-prefix="git checkout -b bugfix/{{ $ticket->reference }}-">
|
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-700 text-white shadow-sm">
|
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-700 text-white shadow-sm">
|
||||||
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
|
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<label for="helpdesk-branch-command-suffix" class="block text-xs font-black uppercase tracking-[0.16em] text-violet-900 dark:text-violet-100">
|
<label for="helpdesk-branch-command-suffix" class="block text-xs font-black uppercase tracking-[0.16em] text-violet-900 dark:text-violet-100">
|
||||||
Branch command
|
Branch create command
|
||||||
</label>
|
</label>
|
||||||
<p class="mt-1 text-xs font-semibold text-violet-800/80 dark:text-violet-200/80">
|
<p class="mt-1 text-xs font-semibold text-violet-800/80 dark:text-violet-200/80">
|
||||||
Ticket reference is fixed. Enter the short branch description after {{ $ticket->reference }}-.
|
Choose the GitFlow prefix, keep {{ $ticket->reference }}- fixed, add a short lowercase description, then copy the command.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 grid gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="grid gap-2 lg:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
<div class="flex min-h-10 flex-1 overflow-hidden rounded-lg border border-violet-200 bg-white shadow-sm focus-within:border-violet-500 focus-within:ring-2 focus-within:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950">
|
<label for="helpdesk-branch-command-prefix" class="sr-only">GitFlow prefix</label>
|
||||||
<span class="hidden items-center border-r border-violet-100 bg-violet-100/70 px-3 font-mono text-[0.68rem] font-black text-violet-900 dark:border-violet-900 dark:bg-violet-950 dark:text-violet-100 sm:inline-flex">
|
<select id="helpdesk-branch-command-prefix"
|
||||||
|
class="min-h-10 rounded-lg border border-violet-200 bg-white px-3 py-2 text-xs font-black text-violet-950 shadow-sm transition hover:border-violet-400 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950 dark:text-violet-100"
|
||||||
|
data-helpdesk-branch-prefix-select>
|
||||||
|
@foreach ($gitflowPrefixes as $gitflowPrefix)
|
||||||
|
<option value="{{ $gitflowPrefix }}" @selected($gitflowPrefix === $defaultGitflowPrefix)>
|
||||||
|
{{ $gitflowPrefix }}/
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="flex min-h-10 overflow-hidden rounded-lg border border-violet-200 bg-white shadow-sm focus-within:border-violet-500 focus-within:ring-2 focus-within:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950">
|
||||||
|
<span class="hidden items-center border-r border-violet-100 bg-violet-100/70 px-3 font-mono text-[0.68rem] font-black text-violet-900 dark:border-violet-900 dark:bg-violet-950 dark:text-violet-100 sm:inline-flex" data-helpdesk-branch-prefix-label>
|
||||||
bugfix/{{ $ticket->reference }}-
|
bugfix/{{ $ticket->reference }}-
|
||||||
</span>
|
</span>
|
||||||
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
|
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
|
||||||
@@ -1330,32 +1460,94 @@
|
|||||||
placeholder="short-description"
|
placeholder="short-description"
|
||||||
data-helpdesk-branch-suffix>
|
data-helpdesk-branch-suffix>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 xl:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<code class="block overflow-x-auto rounded-lg border border-violet-100 bg-white/80 px-3 py-2 font-mono text-[0.68rem] font-bold text-violet-950 dark:border-violet-900 dark:bg-zinc-950/80 dark:text-violet-100"
|
||||||
|
data-helpdesk-branch-command-output>{{ $branchCommandPrefix }}helpdesk-ticket-branch-linking</code>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-violet-700 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-violet-800 focus:outline-none focus:ring-2 focus:ring-violet-400/70"
|
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-violet-700 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-violet-800 focus:outline-none focus:ring-2 focus:ring-violet-400/70"
|
||||||
data-helpdesk-copy-branch-command>
|
data-helpdesk-copy-branch-command>
|
||||||
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
|
||||||
Copy command
|
Copy branch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<code class="block overflow-x-auto rounded-lg border border-violet-100 bg-white/80 px-3 py-2 font-mono text-[0.68rem] font-bold text-violet-950 dark:border-violet-900 dark:bg-zinc-950/80 dark:text-violet-100"
|
|
||||||
data-helpdesk-branch-command-output>git checkout -b bugfix/{{ $ticket->reference }}-helpdesk-ticket-branch-linking</code>
|
|
||||||
<p class="hidden text-xs font-bold text-emerald-700 dark:text-emerald-300" data-helpdesk-branch-copy-status>
|
<p class="hidden text-xs font-bold text-emerald-700 dark:text-emerald-300" data-helpdesk-branch-copy-status>
|
||||||
Copied to clipboard.
|
Copied to clipboard.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-blue-200 bg-white/85 p-3 shadow-sm dark:border-blue-900/70 dark:bg-zinc-950/70"
|
||||||
|
data-helpdesk-copy-template data-helpdesk-template-prefix="{{ $commitMessagePrefix }}" data-helpdesk-template-default="Implement ticket change">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-black uppercase tracking-[0.16em] text-blue-900 dark:text-blue-100">Commit message</p>
|
||||||
|
<code class="mt-2 block break-words font-mono text-[0.68rem] font-bold text-zinc-900 dark:text-zinc-100" data-helpdesk-template-output>{{ $commitMessagePrefix }}Implement ticket change</code>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="shrink-0 rounded-lg bg-blue-600 px-2.5 py-1.5 text-[0.68rem] font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70" data-helpdesk-copy-template-button>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-emerald-200 bg-white/85 p-3 shadow-sm dark:border-emerald-900/70 dark:bg-zinc-950/70"
|
||||||
|
data-helpdesk-copy-template data-helpdesk-template-prefix="{{ $pullRequestTitlePrefix }}" data-helpdesk-template-default="Ready for review">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-black uppercase tracking-[0.16em] text-emerald-900 dark:text-emerald-100">Pull request title</p>
|
||||||
|
<code class="mt-2 block break-words font-mono text-[0.68rem] font-bold text-zinc-900 dark:text-zinc-100" data-helpdesk-template-output>{{ $pullRequestTitlePrefix }}Ready for review</code>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="shrink-0 rounded-lg bg-emerald-600 px-2.5 py-1.5 text-[0.68rem] font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70" data-helpdesk-copy-template-button>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="grid gap-3 border-b border-zinc-100 p-4 dark:border-zinc-800 sm:grid-cols-3">
|
||||||
@forelse ($ticket->gitCommits as $commit)
|
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/70">
|
||||||
<a href="{{ $commit->url ?: '#' }}" target="_blank"
|
<p class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-zinc-500">Default branch</p>
|
||||||
class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800">
|
<p class="mt-1 truncate font-mono text-xs font-bold text-zinc-900 dark:text-zinc-100">{{ $defaultBranch }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/70">
|
||||||
|
<p class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-zinc-500">Latest branch</p>
|
||||||
|
<p class="mt-1 truncate font-mono text-xs font-bold text-zinc-900 dark:text-zinc-100">{{ $latestBranch ?: 'No branch linked' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/70">
|
||||||
|
<p class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-zinc-500">Activity status</p>
|
||||||
|
<p class="mt-1 text-xs font-bold text-zinc-900 dark:text-zinc-100">
|
||||||
|
@if ($gitActivity->isEmpty())
|
||||||
|
Awaiting branch or commit
|
||||||
|
@elseif ($gitActivity->contains(fn ($activity) => $activity->short_hash !== 'branch'))
|
||||||
|
Commit activity linked
|
||||||
|
@else
|
||||||
|
Branch created, awaiting commits
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 p-4">
|
||||||
|
@forelse ($gitActivity as $commit)
|
||||||
|
@php
|
||||||
|
$isBranchActivity = $commit->short_hash === 'branch';
|
||||||
|
@endphp
|
||||||
|
<a href="{{ $commit->url ?: '#' }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="block rounded-lg border border-zinc-100 p-2 transition hover:border-violet-200 hover:bg-violet-50/70 focus:outline-none focus:ring-2 focus:ring-violet-400/40 dark:border-zinc-800 dark:hover:border-violet-900 dark:hover:bg-violet-950/30">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="font-mono text-[0.68rem] text-zinc-500">
|
<div class="font-mono text-[0.68rem] text-zinc-500">
|
||||||
{{ $commit->short_hash === 'branch' ? 'branch' : $commit->short_hash }} ·
|
{{ $isBranchActivity ? 'branch' : $commit->short_hash }} ·
|
||||||
{{ $commit->repository?->name }}
|
{{ $commit->repository?->name }}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="rounded-full px-2 py-1 text-[0.62rem] font-black uppercase tracking-[0.14em] {{ $isBranchActivity ? 'bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-200' : 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200' }}">
|
||||||
|
{{ $isBranchActivity ? 'Branch' : 'Commit' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
@if ($commit->branch)
|
@if ($commit->branch)
|
||||||
<div class="mt-1 break-words font-mono text-[0.68rem] font-semibold text-violet-700 dark:text-violet-300">
|
<div class="mt-1 break-words font-mono text-[0.68rem] font-semibold text-violet-700 dark:text-violet-300">
|
||||||
{{ $commit->branch }}
|
{{ $commit->branch }}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<div class="mb-8 overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-sm">
|
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
<div class="px-6 py-8 text-white">
|
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-blue-300">
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">
|
||||||
{{ $eyebrow ?? 'Helpdesk' }}
|
{{ $eyebrow ?? 'Helpdesk' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 class="mt-3 text-3xl font-bold tracking-tight">
|
<h1 class="mt-2 text-3xl font-semibold tracking-tight">
|
||||||
{{ $title ?? 'Helpdesk' }}
|
{{ $title ?? 'Helpdesk' }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@if (!empty($subtitle))
|
@if (!empty($subtitle))
|
||||||
<p class="mt-2 max-w-3xl text-sm text-slate-300">
|
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">
|
||||||
{{ $subtitle }}
|
{{ $subtitle }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@isset($actions)
|
@isset($actions)
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
{{ $actions }}
|
{{ $actions }}
|
||||||
</div>
|
</div>
|
||||||
@endisset
|
@endisset
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
@php
|
|
||||||
$helpdeskEnabled = Route::has('helpdesk.index');
|
|
||||||
|
|
||||||
if (class_exists(\Nwidart\Modules\Facades\Module::class)) {
|
|
||||||
$helpdeskModule = \Nwidart\Modules\Facades\Module::find('Helpdesk');
|
|
||||||
$helpdeskEnabled = $helpdeskModule !== null && $helpdeskModule->isEnabled() && Route::has('helpdesk.index');
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($helpdeskEnabled)
|
|
||||||
<a href="{{ route('helpdesk.index') }}" wire:navigate
|
|
||||||
class="wms-sidebar-link @if (request()->routeIs('helpdesk.*')) is-active @endif" title="{{ __('Support') }}">
|
|
||||||
<i class="fa-solid fa-headset wms-sidebar-icon" aria-hidden="true"></i>
|
|
||||||
<span data-wms-sidebar-label>{{ __('Support') }}</span>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
|
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
|
||||||
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
|
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
|
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
|
||||||
|
use Modules\Helpdesk\Http\Controllers\TicketBoardController;
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
|
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController;
|
use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController;
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketControlsController;
|
use Modules\Helpdesk\Http\Controllers\TicketControlsController;
|
||||||
@@ -33,6 +34,7 @@ Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit');
|
|||||||
Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
|
Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
|
||||||
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
|
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
|
||||||
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
|
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
|
||||||
|
Route::post('/tickets/{ticket}/board', TicketBoardController::class)->name('tickets.board.update');
|
||||||
Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store');
|
Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store');
|
||||||
Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store');
|
Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store');
|
||||||
Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy');
|
Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy');
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Browser;
|
||||||
|
|
||||||
|
use Laravel\Dusk\Browser;
|
||||||
|
use Modules\Helpdesk\Models\Attachment;
|
||||||
|
use Modules\Helpdesk\Models\Ticket;
|
||||||
|
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
|
final class HelpdeskCreateTicketAttachmentBrowserTest extends DuskTestCase
|
||||||
|
{
|
||||||
|
use BuildsHelpdeskFixtures;
|
||||||
|
|
||||||
|
public function test_user_can_create_ticket_with_attachment_in_browser(): void
|
||||||
|
{
|
||||||
|
$fixtures = $this->seedLookupData();
|
||||||
|
$user = $this->user(['name' => 'Browser Attachment User']);
|
||||||
|
$subject = 'Browser attachment ticket ' . uniqid('', true);
|
||||||
|
$filePath = $this->makeBrowserUploadFile('browser-upload-proof.log', 'Browser upload proof file.');
|
||||||
|
|
||||||
|
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $filePath): void {
|
||||||
|
$browser->loginAs($user)
|
||||||
|
->visitRoute('helpdesk.tickets.create')
|
||||||
|
->waitFor('@helpdesk-ticket-create', 10)
|
||||||
|
->assertSee('Create ticket')
|
||||||
|
->type('@ticket-subject', $subject);
|
||||||
|
|
||||||
|
$browser->attach('#attachments', $filePath);
|
||||||
|
|
||||||
|
$this->waitForHelpdeskUploadToFinish($browser);
|
||||||
|
|
||||||
|
$browser->type('@ticket-subject', $subject);
|
||||||
|
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
|
||||||
|
$this->setHelpdeskEditor(
|
||||||
|
$browser,
|
||||||
|
'ticket-body-editor',
|
||||||
|
'This browser ticket proves attachments can be uploaded and saved.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->submitTicketCreateComponent($browser);
|
||||||
|
|
||||||
|
$this->waitForTicketCreated($browser, $subject);
|
||||||
|
|
||||||
|
$browser->visitRoute('helpdesk.tickets.index')
|
||||||
|
->waitForText($subject, 10)
|
||||||
|
->assertPathIs('/helpdesk/tickets')
|
||||||
|
->assertSee($subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
$ticket = Ticket::query()
|
||||||
|
->where('subject', $subject)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$attachment = Attachment::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->whereNull('comment_id')
|
||||||
|
->where('original_name', 'browser-upload-proof.log')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
self::assertSame($ticket->id, (int) $attachment->ticket_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_create_ticket_with_multiple_attachments_in_browser(): void
|
||||||
|
{
|
||||||
|
$fixtures = $this->seedLookupData();
|
||||||
|
$user = $this->user(['name' => 'Browser Multi Attachment User']);
|
||||||
|
$subject = 'Browser multiple attachment ticket ' . uniqid('', true);
|
||||||
|
$firstFile = $this->makeBrowserUploadFile('browser-first-proof.txt', 'First browser upload proof file.');
|
||||||
|
$secondFile = $this->makeBrowserUploadFile('browser-second-proof.csv', 'name,value' . PHP_EOL . 'proof,1');
|
||||||
|
|
||||||
|
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $firstFile, $secondFile): void {
|
||||||
|
$browser->loginAs($user)
|
||||||
|
->visitRoute('helpdesk.tickets.create')
|
||||||
|
->waitFor('@helpdesk-ticket-create', 10)
|
||||||
|
->assertSee('Create ticket')
|
||||||
|
->type('@ticket-subject', $subject);
|
||||||
|
|
||||||
|
$browser->attach('#attachments', $firstFile . "\n" . $secondFile);
|
||||||
|
|
||||||
|
$this->waitForHelpdeskUploadToFinish($browser);
|
||||||
|
|
||||||
|
$browser->type('@ticket-subject', $subject);
|
||||||
|
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['normal']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
|
||||||
|
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
|
||||||
|
$this->setHelpdeskEditor(
|
||||||
|
$browser,
|
||||||
|
'ticket-body-editor',
|
||||||
|
'This browser ticket proves multiple attachments can be uploaded and saved.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->submitTicketCreateComponent($browser);
|
||||||
|
|
||||||
|
$this->waitForTicketCreated($browser, $subject);
|
||||||
|
|
||||||
|
$browser->visitRoute('helpdesk.tickets.index')
|
||||||
|
->waitForText($subject, 10)
|
||||||
|
->assertPathIs('/helpdesk/tickets')
|
||||||
|
->assertSee($subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
$ticket = Ticket::query()
|
||||||
|
->where('subject', $subject)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
2,
|
||||||
|
Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count(),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (['browser-first-proof.txt', 'browser-second-proof.csv'] as $name) {
|
||||||
|
$this->assertDatabaseHas('helpdesk_attachments', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'comment_id' => null,
|
||||||
|
'original_name' => $name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeBrowserUploadFile(string $name, string $contents): string
|
||||||
|
{
|
||||||
|
$directory = storage_path('framework/testing/helpdesk-browser-uploads');
|
||||||
|
|
||||||
|
if (! is_dir($directory)) {
|
||||||
|
mkdir($directory, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $directory . DIRECTORY_SEPARATOR . $name;
|
||||||
|
file_put_contents($path, $contents);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setHelpdeskEditor(Browser $browser, string $dusk, string $text): void
|
||||||
|
{
|
||||||
|
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
|
||||||
|
$encodedText = json_encode($text, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$browser->script(<<<JS
|
||||||
|
const key = {$encodedDusk};
|
||||||
|
const text = {$encodedText};
|
||||||
|
const root = document.querySelector('[dusk="' + key + '"], [data-dusk="' + key + '"]');
|
||||||
|
|
||||||
|
if (! root) {
|
||||||
|
throw new Error('Helpdesk editor ' + key + ' was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = root.matches('textarea, input, [contenteditable="true"]')
|
||||||
|
? root
|
||||||
|
: root.querySelector('textarea, input, [contenteditable="true"]');
|
||||||
|
|
||||||
|
if (! editor) {
|
||||||
|
throw new Error('Helpdesk editor content area ' + key + ' was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.focus();
|
||||||
|
|
||||||
|
if (editor.matches('textarea, input')) {
|
||||||
|
editor.value = text;
|
||||||
|
} else {
|
||||||
|
editor.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.dispatchEvent(new InputEvent('input', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
inputType: 'insertText',
|
||||||
|
data: text,
|
||||||
|
}));
|
||||||
|
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||||
|
editor.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
|
||||||
|
|
||||||
|
const model = root.getAttribute('wire:model')
|
||||||
|
|| root.getAttribute('wire:model.live')
|
||||||
|
|| root.getAttribute('wire:model.defer')
|
||||||
|
|| root.getAttribute('data-wire-model')
|
||||||
|
|| editor.getAttribute('data-wire-model')
|
||||||
|
|| editor.getAttribute('data-helpdesk-editor-model')
|
||||||
|
|| editor.getAttribute('data-helpdesk-sync-target');
|
||||||
|
|
||||||
|
let componentRoot = root;
|
||||||
|
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
|
||||||
|
componentRoot = componentRoot.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model && componentRoot && window.Livewire) {
|
||||||
|
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.set(model, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$browser->pause(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setHelpdeskSelect(Browser $browser, string $model, int|string|null $value): void
|
||||||
|
{
|
||||||
|
$encodedModel = json_encode($model, JSON_THROW_ON_ERROR);
|
||||||
|
$encodedValue = json_encode($value === null ? '' : (string) $value, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$browser->script(<<<JS
|
||||||
|
const model = {$encodedModel};
|
||||||
|
const value = {$encodedValue};
|
||||||
|
const select = document.querySelector('[data-wire-model="' + model + '"]');
|
||||||
|
|
||||||
|
if (! select) {
|
||||||
|
throw new Error('Helpdesk select ' + model + ' was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||||
|
|
||||||
|
if (window.jQuery) {
|
||||||
|
window.jQuery(select).val(value).trigger('change.select2').trigger('change.helpdesk-select2').trigger('change');
|
||||||
|
}
|
||||||
|
|
||||||
|
let componentRoot = select;
|
||||||
|
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
|
||||||
|
componentRoot = componentRoot.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentRoot && window.Livewire) {
|
||||||
|
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.set(model, value === '' ? null : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$browser->pause(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function submitTicketCreateComponent(Browser $browser): void
|
||||||
|
{
|
||||||
|
$browser->waitUsing(10, 100, static function () use ($browser): bool {
|
||||||
|
return (bool) $browser->script(<<<'JS'
|
||||||
|
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
|
||||||
|
return Boolean(button) && button.disabled === false;
|
||||||
|
JS)[0];
|
||||||
|
}, 'The helpdesk ticket create form was not ready to submit.');
|
||||||
|
|
||||||
|
$browser->script(<<<'JS'
|
||||||
|
const root = document.querySelector('[dusk="helpdesk-ticket-create"], [data-dusk="helpdesk-ticket-create"]');
|
||||||
|
|
||||||
|
if (! root) {
|
||||||
|
throw new Error('Helpdesk ticket create root was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let componentRoot = root;
|
||||||
|
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
|
||||||
|
componentRoot = componentRoot.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! componentRoot || ! window.Livewire) {
|
||||||
|
throw new Error('Helpdesk Livewire create component was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
|
||||||
|
|
||||||
|
if (! component) {
|
||||||
|
throw new Error('Helpdesk Livewire create component instance was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
component.call('save');
|
||||||
|
JS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitForHelpdeskUploadToFinish(Browser $browser): void
|
||||||
|
{
|
||||||
|
$browser->waitUsing(30, 250, static function () use ($browser): bool {
|
||||||
|
return (bool) $browser->script(<<<'JS'
|
||||||
|
const input = document.querySelector('#attachments');
|
||||||
|
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
|
||||||
|
const selected = document.querySelector('[dusk="ticket-create-selected-attachments"], [data-dusk="ticket-create-selected-attachments"]');
|
||||||
|
|
||||||
|
return Boolean(input)
|
||||||
|
&& input.files.length > 0
|
||||||
|
&& Boolean(selected)
|
||||||
|
&& Boolean(button)
|
||||||
|
&& button.disabled === false;
|
||||||
|
JS)[0];
|
||||||
|
}, 'The Livewire temporary attachment upload did not finish before submit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitForTicketCreated(Browser $browser, string $subject): void
|
||||||
|
{
|
||||||
|
$browser->waitUsing(20, 250, static fn (): bool => Ticket::query()
|
||||||
|
->where('subject', $subject)
|
||||||
|
->exists(), 'The browser did not create the helpdesk ticket before the timeout.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,44 @@ final class KanbanBoardTest extends TestCase
|
|||||||
->assertSee('High');
|
->assertSee('High');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function test_ticket_can_be_moved_to_another_board(): void
|
||||||
|
{
|
||||||
|
$lookups = $this->seedLookupData();
|
||||||
|
$user = $this->admin();
|
||||||
|
|
||||||
|
$boardA = $lookups['board'];
|
||||||
|
|
||||||
|
$boardB = KanbanBoardModel::query()->create([
|
||||||
|
'name' => 'Regression Target Board',
|
||||||
|
'slug' => 'regression-target-board',
|
||||||
|
'is_default' => false,
|
||||||
|
'sort_order' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$boardB->statuses()->sync([
|
||||||
|
$lookups['open']->id => ['sort_order' => 10],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket = $this->ticket([
|
||||||
|
'owner' => $user,
|
||||||
|
'status_id' => $lookups['open']->id,
|
||||||
|
'priority_id' => $lookups['normal']->id,
|
||||||
|
'board_id' => $boardA->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(KanbanBoard::class)
|
||||||
|
->call('moveTicketToBoard', $ticket->id, $boardB->id)
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$ticket->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($boardB->id, $ticket->board_id);
|
||||||
|
$this->assertSame($lookups['open']->id, $ticket->status_id);
|
||||||
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function kanban_board_can_move_ticket_to_closed_status(): void
|
public function kanban_board_can_move_ticket_to_closed_status(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ final class TicketBranchCommandUiFilesTest extends TestCase
|
|||||||
self::assertStringContainsString('data-helpdesk-branch-command', $view);
|
self::assertStringContainsString('data-helpdesk-branch-command', $view);
|
||||||
self::assertStringContainsString('git checkout -b bugfix/{{ $ticket->reference }}-', $view);
|
self::assertStringContainsString('git checkout -b bugfix/{{ $ticket->reference }}-', $view);
|
||||||
self::assertStringContainsString('data-helpdesk-branch-suffix', $view);
|
self::assertStringContainsString('data-helpdesk-branch-suffix', $view);
|
||||||
|
self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view);
|
||||||
|
self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view);
|
||||||
|
self::assertStringContainsString('feature/', $view);
|
||||||
|
self::assertStringContainsString('bugfix/', $view);
|
||||||
|
self::assertStringContainsString('hotfix/', $view);
|
||||||
|
self::assertStringContainsString('support/', $view);
|
||||||
self::assertStringContainsString('data-helpdesk-copy-branch-command', $view);
|
self::assertStringContainsString('data-helpdesk-copy-branch-command', $view);
|
||||||
self::assertStringContainsString('copyHelpdeskBranchCommand', $view);
|
self::assertStringContainsString('copyHelpdeskBranchCommand', $view);
|
||||||
self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view);
|
self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Modules\Helpdesk\Tests\Feature;
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class TicketCreateAttachmentBrowserSafetyTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_create_uses_standard_livewire_temporary_uploads(): void
|
||||||
|
{
|
||||||
|
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
|
||||||
|
|
||||||
|
self::assertIsString($view);
|
||||||
|
self::assertStringContainsString('wire:submit.prevent="save"', $view);
|
||||||
|
self::assertStringContainsString('type="file"', $view);
|
||||||
|
self::assertStringContainsString('wire:model="attachments"', $view);
|
||||||
|
self::assertStringContainsString('multiple', $view);
|
||||||
|
self::assertStringContainsString('wire:key="ticket-create-attachments-input"', $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ticket_create_does_not_mix_existing_ticket_upload_behaviour_into_create_form(): void
|
||||||
|
{
|
||||||
|
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
|
||||||
|
|
||||||
|
self::assertIsString($view);
|
||||||
|
self::assertStringNotContainsString('@vite([\'resources/css/app.css\', \'resources/js/app.js\'])', $view);
|
||||||
|
self::assertStringNotContainsString('helpdeskAttachmentDropzone', $view);
|
||||||
|
self::assertStringNotContainsString('DataTransfer', $view);
|
||||||
|
self::assertStringNotContainsString('$wire.uploadMultiple', $view);
|
||||||
|
self::assertStringNotContainsString('input.files =', $view);
|
||||||
|
self::assertStringNotContainsString('helpdesk-editor-files-pasted', $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ticket_create_shows_uploaded_temporary_file_names_from_livewire_property(): void
|
||||||
|
{
|
||||||
|
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
|
||||||
|
|
||||||
|
self::assertIsString($view);
|
||||||
|
self::assertStringContainsString('@if (count($attachments) > 0)', $view);
|
||||||
|
self::assertStringContainsString('$attachment->getClientOriginalName()', $view);
|
||||||
|
self::assertStringContainsString('wire:loading wire:target="attachments"', $view);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\Helpdesk\Livewire\TicketCreate;
|
||||||
|
use Modules\Helpdesk\Models\Attachment;
|
||||||
|
use Modules\Helpdesk\Models\Ticket;
|
||||||
|
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class TicketCreateAttachmentPipelineTest extends TestCase
|
||||||
|
{
|
||||||
|
use BuildsHelpdeskFixtures;
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_ticket_create_persists_a_single_attachment_through_livewire(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('helpdesk.disk', 'local');
|
||||||
|
|
||||||
|
$fixtures = $this->seedLookupData();
|
||||||
|
$user = $this->user(['name' => 'Attachment Create User']);
|
||||||
|
$file = UploadedFile::fake()->create('warehouse-error.log', 12, 'text/plain');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TicketCreate::class)
|
||||||
|
->set('subject', 'Create ticket with one attachment')
|
||||||
|
->set('body', '<p>This ticket must persist a Livewire upload attachment.</p>')
|
||||||
|
->set('board_id', $fixtures['board']->id)
|
||||||
|
->set('priority_id', $fixtures['high']->id)
|
||||||
|
->set('category_id', $fixtures['category']->id)
|
||||||
|
->set('team_id', $fixtures['team']->id)
|
||||||
|
->set('project_id', $fixtures['project']->id)
|
||||||
|
->set('attachments', [$file])
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(route('helpdesk.tickets.index'));
|
||||||
|
|
||||||
|
$ticket = Ticket::query()
|
||||||
|
->where('subject', 'Create ticket with one attachment')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$attachment = Attachment::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->whereNull('comment_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
self::assertSame($user->id, (int) $attachment->user_id);
|
||||||
|
self::assertSame('local', $attachment->disk);
|
||||||
|
self::assertSame('warehouse-error.log', $attachment->original_name);
|
||||||
|
self::assertSame('log', $attachment->extension());
|
||||||
|
Storage::disk('local')->assertExists($attachment->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ticket_create_persists_multiple_attachments_through_livewire(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('helpdesk.disk', 'local');
|
||||||
|
|
||||||
|
$fixtures = $this->seedLookupData();
|
||||||
|
$user = $this->user(['name' => 'Multiple Attachment User']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TicketCreate::class)
|
||||||
|
->set('subject', 'Create ticket with multiple attachments')
|
||||||
|
->set('body', '<p>This ticket must persist every selected Livewire upload.</p>')
|
||||||
|
->set('board_id', $fixtures['board']->id)
|
||||||
|
->set('priority_id', $fixtures['normal']->id)
|
||||||
|
->set('category_id', $fixtures['category']->id)
|
||||||
|
->set('team_id', $fixtures['team']->id)
|
||||||
|
->set('project_id', $fixtures['project']->id)
|
||||||
|
->set('attachments', [
|
||||||
|
UploadedFile::fake()->create('first-proof.pdf', 20, 'application/pdf'),
|
||||||
|
UploadedFile::fake()->create('second-proof.txt', 8, 'text/plain'),
|
||||||
|
UploadedFile::fake()->create('third-proof.csv', 6, 'text/csv'),
|
||||||
|
])
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(route('helpdesk.tickets.index'));
|
||||||
|
|
||||||
|
$ticket = Ticket::query()
|
||||||
|
->where('subject', 'Create ticket with multiple attachments')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
self::assertSame(3, Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count());
|
||||||
|
|
||||||
|
foreach (['first-proof.pdf', 'second-proof.txt', 'third-proof.csv'] as $name) {
|
||||||
|
$attachment = Attachment::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->where('original_name', $name)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
Storage::disk('local')->assertExists($attachment->path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ticket_create_accepts_source_code_attachments_through_livewire(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('helpdesk.disk', 'local');
|
||||||
|
|
||||||
|
$fixtures = $this->seedLookupData();
|
||||||
|
$user = $this->user(['name' => 'Source Attachment User']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TicketCreate::class)
|
||||||
|
->set('subject', 'Create ticket with source attachments')
|
||||||
|
->set('body', '<p>This ticket must accept source files by extension.</p>')
|
||||||
|
->set('board_id', $fixtures['board']->id)
|
||||||
|
->set('priority_id', $fixtures['normal']->id)
|
||||||
|
->set('category_id', $fixtures['category']->id)
|
||||||
|
->set('team_id', $fixtures['team']->id)
|
||||||
|
->set('project_id', $fixtures['project']->id)
|
||||||
|
->set('attachments', [
|
||||||
|
UploadedFile::fake()->create('TicketCreate.php', 10, 'text/plain'),
|
||||||
|
UploadedFile::fake()->create('upload-widget.js', 10, 'text/plain'),
|
||||||
|
UploadedFile::fake()->create('warehouse-sync.rpgle', 10, 'text/plain'),
|
||||||
|
])
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(route('helpdesk.tickets.index'));
|
||||||
|
|
||||||
|
$ticket = Ticket::query()
|
||||||
|
->where('subject', 'Create ticket with source attachments')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
foreach (['TicketCreate.php', 'upload-widget.js', 'warehouse-sync.rpgle'] as $name) {
|
||||||
|
$attachment = Attachment::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->where('original_name', $name)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
self::assertTrue($attachment->isSourceCode(), $name . ' should be categorised as source code.');
|
||||||
|
Storage::disk('local')->assertExists($attachment->path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TicketDeveloperActionsUiFilesTest.php
|
||||||
|
*
|
||||||
|
* Guards the ticket-level developer actions workflow against UI regressions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class TicketDeveloperActionsUiFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure the Git activity card has been promoted to a complete developer actions panel.
|
||||||
|
*/
|
||||||
|
public function test_ticket_show_exposes_developer_actions_panel(): void
|
||||||
|
{
|
||||||
|
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
|
||||||
|
$view = file_get_contents($viewPath);
|
||||||
|
|
||||||
|
self::assertIsString($view);
|
||||||
|
self::assertStringContainsString('data-helpdesk-developer-actions', $view);
|
||||||
|
self::assertStringContainsString('Developer actions', $view);
|
||||||
|
self::assertStringContainsString('Open repo', $view);
|
||||||
|
self::assertStringContainsString('Open branch', $view);
|
||||||
|
self::assertStringContainsString('Pull requests', $view);
|
||||||
|
self::assertStringContainsString('Activity status', $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the panel keeps copyable GitFlow branch, commit and pull request templates.
|
||||||
|
*/
|
||||||
|
public function test_ticket_show_exposes_copyable_gitflow_templates(): void
|
||||||
|
{
|
||||||
|
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
|
||||||
|
$view = file_get_contents($viewPath);
|
||||||
|
|
||||||
|
self::assertIsString($view);
|
||||||
|
self::assertStringContainsString('Branch create command', $view);
|
||||||
|
self::assertStringContainsString('\'git checkout -b bugfix/\' . $ticket->reference . \'-\'', $view);
|
||||||
|
self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view);
|
||||||
|
self::assertStringContainsString('$gitflowPrefixes', $view);
|
||||||
|
self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view);
|
||||||
|
self::assertStringContainsString('Commit message', $view);
|
||||||
|
self::assertStringContainsString('Pull request title', $view);
|
||||||
|
self::assertStringContainsString('data-helpdesk-copy-template', $view);
|
||||||
|
self::assertStringContainsString('data-helpdesk-copy-template-button', $view);
|
||||||
|
self::assertStringContainsString('copyHelpdeskTemplate', $view);
|
||||||
|
self::assertStringContainsString('copyTextToClipboard', $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure linked Git activity still renders branch and commit badges instead of being hidden by the new panel.
|
||||||
|
*/
|
||||||
|
public function test_ticket_show_still_renders_linked_git_activity_rows(): void
|
||||||
|
{
|
||||||
|
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
|
||||||
|
$view = file_get_contents($viewPath);
|
||||||
|
|
||||||
|
self::assertIsString($view);
|
||||||
|
self::assertStringContainsString('@forelse ($gitActivity as $commit)', $view);
|
||||||
|
self::assertStringContainsString('$isBranchActivity = $commit->short_hash === \'branch\';', $view);
|
||||||
|
self::assertStringContainsString('{{ $isBranchActivity ? \'Branch\' : \'Commit\' }}', $view);
|
||||||
|
self::assertStringContainsString('No Git activity linked.', $view);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class TicketIndexDirectUserNPlusOneFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_index_eager_loads_required_relations(): void
|
||||||
|
{
|
||||||
|
$source = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
|
||||||
|
|
||||||
|
self::assertIsString($source);
|
||||||
|
|
||||||
|
self::assertStringContainsString("->with(['status', 'priority', 'category', 'project', 'team'])", $source);
|
||||||
|
self::assertStringNotContainsString("'user:id,name,email'", $source);
|
||||||
|
self::assertStringNotContainsString("'owner:id,name,email'", $source);
|
||||||
|
self::assertStringNotContainsString("'agent:id,name,email'", $source);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class TicketIndexUserNPlusOneFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_index_uses_scalar_agent_name(): void
|
||||||
|
{
|
||||||
|
$component = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
|
||||||
|
$view = file_get_contents(dirname(__DIR__, 2).'/Resources/views/livewire/ticket-index.blade.php');
|
||||||
|
|
||||||
|
self::assertIsString($component);
|
||||||
|
self::assertIsString($view);
|
||||||
|
|
||||||
|
self::assertStringContainsString("->select('helpdesk_tickets.*')", $component);
|
||||||
|
self::assertStringContainsString("'agent_name' => User::query()", $component);
|
||||||
|
self::assertStringContainsString("->whereColumn('users.id', 'helpdesk_tickets.agent_id')", $component);
|
||||||
|
|
||||||
|
self::assertStringContainsString('$ticket->agent_name ?? \'Unassigned\'', $view);
|
||||||
|
self::assertStringNotContainsString('$ticket->agent?->name ?? \'Unassigned\'', $view);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class TicketShowCommentRelationsFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_show_only_eager_loads_real_comment_relations(): void
|
||||||
|
{
|
||||||
|
$source = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketShow.php');
|
||||||
|
|
||||||
|
self::assertIsString($source);
|
||||||
|
self::assertStringContainsString("->with(['user.company', 'attachments'])", $source);
|
||||||
|
self::assertStringNotContainsString("->with(['user.company', 'owner:id,name,email,company_id', 'agent:id,name,email', 'attachments'])", $source);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class TicketUserNPlusOneFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ticket_queries_only_eager_load_required_relations(): void
|
||||||
|
{
|
||||||
|
$index = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
|
||||||
|
$kanban = file_get_contents(dirname(__DIR__, 2).'/Livewire/KanbanBoard.php');
|
||||||
|
$dashboard = file_get_contents(dirname(__DIR__, 2).'/Livewire/HelpdeskDashboardWidget.php');
|
||||||
|
|
||||||
|
self::assertIsString($index);
|
||||||
|
self::assertIsString($kanban);
|
||||||
|
self::assertIsString($dashboard);
|
||||||
|
|
||||||
|
// Index deliberately avoids loading user relations.
|
||||||
|
self::assertStringNotContainsString("'user:id,name,email'", $index);
|
||||||
|
self::assertStringNotContainsString("'owner:id,name,email'", $index);
|
||||||
|
self::assertStringNotContainsString("'agent:id,name,email'", $index);
|
||||||
|
|
||||||
|
self::assertStringContainsString("'owner:id,name,email'", $kanban);
|
||||||
|
|
||||||
|
self::assertStringContainsString("'agent:id,name,email'", $kanban);
|
||||||
|
|
||||||
|
// Dashboard intentionally only loads the relations it renders.
|
||||||
|
|
||||||
|
self::assertStringContainsString("->with(['status', 'priority'])", $dashboard);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString("'owner:id,name,email'", $dashboard);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString("'agent:id,name,email'", $dashboard);
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
-129
@@ -1,5 +1,5 @@
|
|||||||
- version: 0.7.0
|
- version: "0.7.0"
|
||||||
date: 2026-06-22
|
date: "2026-06-22"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Add database-backed user-specific ticket timer controls.
|
summary: Add database-backed user-specific ticket timer controls.
|
||||||
changes:
|
changes:
|
||||||
@@ -7,8 +7,9 @@
|
|||||||
- Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available.
|
- Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available.
|
||||||
- Added a reusable Blade include for placing the timer above the existing manual time log form.
|
- Added a reusable Blade include for placing the timer above the existing manual time log form.
|
||||||
- Added regression tests for module path, Livewire view root safety, and non-session timer state.
|
- Added regression tests for module path, Livewire view root safety, and non-session timer state.
|
||||||
- version: 1.9.30
|
|
||||||
date: 2026-06-23
|
- version: "1.9.30"
|
||||||
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Clean up Helpdesk timer UI state and remove duplicate timer display code.
|
summary: Clean up Helpdesk timer UI state and remove duplicate timer display code.
|
||||||
changes:
|
changes:
|
||||||
@@ -19,30 +20,33 @@
|
|||||||
- Registered the timer Livewire component with the helpdesk.ticket-timer alias.
|
- Registered the timer Livewire component with the helpdesk.ticket-timer alias.
|
||||||
- Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module.
|
- Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module.
|
||||||
- Added file-level regression coverage for the timer UI cleanup.
|
- Added file-level regression coverage for the timer UI cleanup.
|
||||||
- version: 1.9.31
|
|
||||||
date: 2026-06-23
|
- version: "1.9.31"
|
||||||
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Reduce Helpdesk start timer button size.
|
summary: Reduce Helpdesk start timer button size.
|
||||||
changes:
|
changes:
|
||||||
- Made the inactive-state Start timer button more compact while preserving accessible focus and loading states.
|
- Made the inactive-state Start timer button more compact while preserving accessible focus and loading states.
|
||||||
- Kept the timer display hidden before start and after end, with no new standalone JavaScript files.
|
- Kept the timer display hidden before start and after end, with no new standalone JavaScript files.
|
||||||
- version: 1.9.32
|
|
||||||
date: 2026-06-23
|
- version: "1.9.32"
|
||||||
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Widen Helpdesk start timer button so the label fits on one line.
|
summary: Widen Helpdesk start timer button so the label fits on one line.
|
||||||
changes:
|
changes:
|
||||||
- Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split.
|
- Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split.
|
||||||
- Preserved the clean inline timer display behaviour with no unused standalone JavaScript file.
|
- Preserved the clean inline timer display behaviour with no unused standalone JavaScript file.
|
||||||
|
|
||||||
- version: 1.9.33
|
- version: "1.9.33"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Widen Helpdesk end timer button so the label fits on one line.
|
summary: Widen Helpdesk end timer button so the label fits on one line.
|
||||||
changes:
|
changes:
|
||||||
- Updated the active End timer action with the same minimum width and no-wrap text treatment as the Start timer button.
|
- Updated the active End timer action with the same minimum width and no-wrap text treatment as the Start timer button.
|
||||||
- Preserved the inline timer updater and avoided adding unused standalone JavaScript files.
|
- Preserved the inline timer updater and avoided adding unused standalone JavaScript files.
|
||||||
- version: 1.9.34
|
|
||||||
date: 2026-06-23
|
- version: "1.9.34"
|
||||||
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Add explicit public/internal response controls to Helpdesk ticket conversations.
|
summary: Add explicit public/internal response controls to Helpdesk ticket conversations.
|
||||||
changes:
|
changes:
|
||||||
@@ -51,8 +55,8 @@
|
|||||||
- Added public/internal badges to conversation entries so response visibility is obvious after posting.
|
- Added public/internal badges to conversation entries so response visibility is obvious after posting.
|
||||||
- Preserved append-only changelog history and avoided adding unused JavaScript or orphan files.
|
- Preserved append-only changelog history and avoided adding unused JavaScript or orphan files.
|
||||||
|
|
||||||
- version: 1.9.35
|
- version: "1.9.35"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk first-response SLA status after public replies.
|
summary: Fix Helpdesk first-response SLA status after public replies.
|
||||||
changes:
|
changes:
|
||||||
@@ -61,8 +65,8 @@
|
|||||||
- Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded.
|
- Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded.
|
||||||
- Added regression coverage for public response SLA state and preserved append-only changelog history.
|
- Added regression coverage for public response SLA state and preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.36
|
- version: "1.9.36"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Add operational SLA status detail to Helpdesk ticket pages.
|
summary: Add operational SLA status detail to Helpdesk ticket pages.
|
||||||
changes:
|
changes:
|
||||||
@@ -73,8 +77,8 @@
|
|||||||
- Updated public reply counting to include threaded public replies as well as root comments.
|
- Updated public reply counting to include threaded public replies as well as root comments.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.37
|
- version: "1.9.37"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: documentation
|
type: documentation
|
||||||
summary: Add Helpdesk module README instructions for closing tickets and SLA response handling.
|
summary: Add Helpdesk module README instructions for closing tickets and SLA response handling.
|
||||||
changes:
|
changes:
|
||||||
@@ -83,8 +87,8 @@
|
|||||||
- Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards.
|
- Documented recommended close-ticket UI behaviour, closure templates, SLA meanings, audit expectations, reopening guidance, and Helpdesk development standards.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.38
|
- version: "1.9.38"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Enforce mandatory closure comments when closing Helpdesk tickets.
|
summary: Enforce mandatory closure comments when closing Helpdesk tickets.
|
||||||
changes:
|
changes:
|
||||||
@@ -95,8 +99,8 @@
|
|||||||
- Added regression tests for blocked closure without a comment and successful closure with an auditable comment.
|
- Added regression tests for blocked closure without a comment and successful closure with an auditable comment.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.39
|
- version: "1.9.39"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk ticket create editor body synchronisation.
|
summary: Fix Helpdesk ticket create editor body synchronisation.
|
||||||
changes:
|
changes:
|
||||||
@@ -106,8 +110,8 @@
|
|||||||
- Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload.
|
- Changed the hidden body field to live Livewire binding so the server receives the latest editor content without requiring a page reload.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.40
|
- version: "1.9.40"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Prevent duplicate Helpdesk ticket references during ticket creation.
|
summary: Prevent duplicate Helpdesk ticket references during ticket creation.
|
||||||
changes:
|
changes:
|
||||||
@@ -117,8 +121,8 @@
|
|||||||
- Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally.
|
- Added regression coverage for out-of-order ticket references so lower references cannot be reused accidentally.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.41
|
- version: "1.9.41"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries.
|
summary: Fix Helpdesk ticket reference collisions caused by soft-deleted references and remove category agent N+1 queries.
|
||||||
changes:
|
changes:
|
||||||
@@ -128,8 +132,8 @@
|
|||||||
- Added regression coverage to ensure soft-deleted ticket references are never reused.
|
- Added regression coverage to ensure soft-deleted ticket references are never reused.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.42
|
- version: "1.9.42"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Stop premature Helpdesk ticket create validation and single-click submit failures.
|
summary: Stop premature Helpdesk ticket create validation and single-click submit failures.
|
||||||
changes:
|
changes:
|
||||||
@@ -140,8 +144,8 @@
|
|||||||
- Added regression coverage proving draft edits do not fire validation before submit.
|
- Added regression coverage proving draft edits do not fire validation before submit.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.43
|
- version: "1.9.43"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage.
|
summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage.
|
||||||
changes:
|
changes:
|
||||||
@@ -152,8 +156,8 @@
|
|||||||
- Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows.
|
- Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.44
|
- version: "1.9.44"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Make Helpdesk ticket create spellcheck visible and repair Clear button DOM reset behaviour.
|
summary: Make Helpdesk ticket create spellcheck visible and repair Clear button DOM reset behaviour.
|
||||||
changes:
|
changes:
|
||||||
@@ -163,8 +167,8 @@
|
|||||||
- Added regression coverage to prevent the visible spellcheck marker and clear-form reset wiring from being removed accidentally.
|
- Added regression coverage to prevent the visible spellcheck marker and clear-form reset wiring from being removed accidentally.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.45
|
- version: "1.9.45"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Make Helpdesk spellcheck suggestions available through native text areas.
|
summary: Make Helpdesk spellcheck suggestions available through native text areas.
|
||||||
changes:
|
changes:
|
||||||
@@ -175,8 +179,8 @@
|
|||||||
- Updated regression coverage for visible spellcheck suggestion guidance.
|
- Updated regression coverage for visible spellcheck suggestion guidance.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.46
|
- version: "1.9.46"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
changes:
|
changes:
|
||||||
- Restored rich text HTML controls for ticket details and main ticket replies.
|
- Restored rich text HTML controls for ticket details and main ticket replies.
|
||||||
- Enabled inline browser spellcheck on contenteditable rich editor areas without replacing the editor with plain textareas.
|
- Enabled inline browser spellcheck on contenteditable rich editor areas without replacing the editor with plain textareas.
|
||||||
@@ -184,16 +188,17 @@
|
|||||||
- Fixed close-ticket modal so selecting a closed status opens the professional closure prompt on the first interaction.
|
- Fixed close-ticket modal so selecting a closed status opens the professional closure prompt on the first interaction.
|
||||||
- Preserved the mandatory closure comment workflow and public/internal closure visibility behaviour.
|
- Preserved the mandatory closure comment workflow and public/internal closure visibility behaviour.
|
||||||
- Added regression coverage notes for rich editor, clear defaults and close-modal workflow.
|
- Added regression coverage notes for rich editor, clear defaults and close-modal workflow.
|
||||||
- version: 1.9.47
|
|
||||||
date: 2026-06-23
|
- version: "1.9.47"
|
||||||
|
date: "2026-06-23"
|
||||||
changes:
|
changes:
|
||||||
- Preserved the rich HTML editor controls while keeping browser-native spellcheck enabled.
|
- Preserved the rich HTML editor controls while keeping browser-native spellcheck enabled.
|
||||||
- Documented that fake inline spellcheck must not replace the rich editor.
|
- Documented that fake inline spellcheck must not replace the rich editor.
|
||||||
- Refreshed ticket time aggregates and the time-log modal after timer start and stop without requiring a page reload.
|
- Refreshed ticket time aggregates and the time-log modal after timer start and stop without requiring a page reload.
|
||||||
- Added regression coverage for timer refresh and spellcheck/editor boundary behaviour.
|
- Added regression coverage for timer refresh and spellcheck/editor boundary behaviour.
|
||||||
|
|
||||||
- version: 1.9.48
|
- version: "1.9.48"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Standardise Helpdesk lookup colours and replace free-text colour fields with dropdown selectors.
|
summary: Standardise Helpdesk lookup colours and replace free-text colour fields with dropdown selectors.
|
||||||
changes:
|
changes:
|
||||||
@@ -205,8 +210,8 @@
|
|||||||
- Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour.
|
- Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.49
|
- version: "1.9.49"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows.
|
summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows.
|
||||||
changes:
|
changes:
|
||||||
@@ -219,8 +224,8 @@
|
|||||||
- Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering.
|
- Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.50
|
- version: "1.9.50"
|
||||||
date: 2026-06-23
|
date: "2026-06-23"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk manual time logging authorization and persistence regression coverage.
|
summary: Fix Helpdesk manual time logging authorization and persistence regression coverage.
|
||||||
changes:
|
changes:
|
||||||
@@ -229,16 +234,16 @@
|
|||||||
- Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy.
|
- Updated TicketTimeLogTest to use authorised Helpdesk fixture users and lookup data instead of ad-hoc users that fail the ticket update policy.
|
||||||
- Kept the pricing-free dashboard test baseline and preserved append-only changelog history.
|
- Kept the pricing-free dashboard test baseline and preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.52
|
- version: "1.9.52"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
changes:
|
changes:
|
||||||
- Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests.
|
- Fixed ticket create rich editor Livewire synchronisation so typing no longer fires keypress/input requests.
|
||||||
- Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM.
|
- Restored native browser spellcheck on the contenteditable editor by allowing typed text to remain stable in the DOM.
|
||||||
- Added submit-capture body synchronisation before validation so tickets can be created reliably.
|
- Added submit-capture body synchronisation before validation so tickets can be created reliably.
|
||||||
- Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync.
|
- Added regression assertions for editor spellcheck, keypress sync prevention, and submit-time body sync.
|
||||||
|
|
||||||
- version: 1.9.53
|
- version: "1.9.53"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Allow Helpdesk ticket attachments to include source-code files.
|
summary: Allow Helpdesk ticket attachments to include source-code files.
|
||||||
changes:
|
changes:
|
||||||
@@ -247,15 +252,16 @@
|
|||||||
- Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules.
|
- Updated ticket create, ticket edit, ticket comments and supporting-file upload validation to use the shared attachment rules.
|
||||||
- Added source-file accept hints to Helpdesk file inputs.
|
- Added source-file accept hints to Helpdesk file inputs.
|
||||||
- Added regression coverage for PHP, JavaScript and RPG source-file attachment support.
|
- Added regression coverage for PHP, JavaScript and RPG source-file attachment support.
|
||||||
- version: 1.9.54
|
|
||||||
date: 2026-06-24
|
- version: "1.9.54"
|
||||||
|
date: "2026-06-24"
|
||||||
changes:
|
changes:
|
||||||
- Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated.
|
- Removed the new AttachmentValidation support class dependency so source file attachment validation cannot fail when Composer optimized autoload has not been regenerated.
|
||||||
- Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files.
|
- Kept source-code attachment support using Laravel extension validation for PHP, JavaScript, RPG/RPGLE and other source/config files.
|
||||||
- Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers.
|
- Updated ticket create/edit/show and attachment controllers to use existing-class validation helpers.
|
||||||
|
|
||||||
- version: 1.9.55
|
- version: "1.9.55"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Persist attachments when creating Helpdesk tickets.
|
summary: Persist attachments when creating Helpdesk tickets.
|
||||||
changes:
|
changes:
|
||||||
@@ -265,8 +271,8 @@
|
|||||||
- Added regression coverage for create-form upload guarding and Livewire temporary upload support.
|
- Added regression coverage for create-form upload guarding and Livewire temporary upload support.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.0
|
- version: "1.10.0"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Improve Helpdesk attachment previews, source-file handling and upload experience.
|
summary: Improve Helpdesk attachment previews, source-file handling and upload experience.
|
||||||
changes:
|
changes:
|
||||||
@@ -278,8 +284,8 @@
|
|||||||
- Added download counting and soft-delete attachment controls with authorization.
|
- Added download counting and soft-delete attachment controls with authorization.
|
||||||
- Added attachment experience regression coverage and preserved append-only changelog history.
|
- Added attachment experience regression coverage and preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.1
|
- version: "1.10.1"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk attachment preview route registration.
|
summary: Fix Helpdesk attachment preview route registration.
|
||||||
changes:
|
changes:
|
||||||
@@ -288,8 +294,8 @@
|
|||||||
- Preserved image, PDF and source-code previews plus attachment soft-delete behaviour.
|
- Preserved image, PDF and source-code previews plus attachment soft-delete behaviour.
|
||||||
- Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes.
|
- Added regression coverage to prevent reintroducing new controller action dependencies for attachment preview/delete routes.
|
||||||
|
|
||||||
- version: 1.10.2
|
- version: "1.10.2"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk attachment model helpers for databases without new metadata columns.
|
summary: Fix Helpdesk attachment model helpers for databases without new metadata columns.
|
||||||
changes:
|
changes:
|
||||||
@@ -300,8 +306,8 @@
|
|||||||
- Added regression coverage for missing-attribute-safe attachment helpers.
|
- Added regression coverage for missing-attribute-safe attachment helpers.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.3
|
- version: "1.10.3"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Include Helpdesk source attachment preview view in changed-files deployments.
|
summary: Include Helpdesk source attachment preview view in changed-files deployments.
|
||||||
changes:
|
changes:
|
||||||
@@ -310,15 +316,15 @@
|
|||||||
- Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it.
|
- Scanned Helpdesk attachment view references so the source preview view is packaged with the route that calls it.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.4
|
- version: "1.10.4"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
changes:
|
changes:
|
||||||
- Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly.
|
- Fixed source attachment preview rendering to use the Helpdesk module layout instead of the root component layout directly.
|
||||||
- Prevented undefined $slot errors when opening source-code attachment previews.
|
- Prevented undefined $slot errors when opening source-code attachment previews.
|
||||||
- Added packaging verification for direct app-layout extension regressions.
|
- Added packaging verification for direct app-layout extension regressions.
|
||||||
|
|
||||||
- version: 1.10.5
|
- version: "1.10.5"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: feature
|
type: feature
|
||||||
summary: Improve Helpdesk back navigation from tickets and attachment previews.
|
summary: Improve Helpdesk back navigation from tickets and attachment previews.
|
||||||
changes:
|
changes:
|
||||||
@@ -327,8 +333,8 @@
|
|||||||
- Added a direct Dashboard button on source attachment preview pages.
|
- Added a direct Dashboard button on source attachment preview pages.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.6
|
- version: "1.10.6"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix source preview navigation crash and polish Helpdesk ticket navigation.
|
summary: Fix source preview navigation crash and polish Helpdesk ticket navigation.
|
||||||
changes:
|
changes:
|
||||||
@@ -338,9 +344,8 @@
|
|||||||
- Collapsed ticket detail sidebar cards by default using a progressive native details wrapper.
|
- Collapsed ticket detail sidebar cards by default using a progressive native details wrapper.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.7"
|
||||||
- version: 1.10.7
|
date: "2026-06-24"
|
||||||
date: 2026-06-24
|
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix source preview navigation and ticket detail UI polish.
|
summary: Fix source preview navigation and ticket detail UI polish.
|
||||||
changes:
|
changes:
|
||||||
@@ -351,14 +356,14 @@
|
|||||||
- Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default.
|
- Ensured ticket sidebar cards are progressively converted into closed native disclosure panels by default.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.7
|
- version: "1.10.7"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
changes:
|
changes:
|
||||||
- Reduced the Helpdesk dashboard hero height to preserve screen space.
|
- Reduced the Helpdesk dashboard hero height to preserve screen space.
|
||||||
- Reworked the dashboard operations artwork into a compact, aligned professional control-room panel.
|
- Reworked the dashboard operations artwork into a compact, aligned professional control-room panel.
|
||||||
|
|
||||||
- version: 1.10.8
|
- version: "1.10.8"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard.
|
summary: Replace the placeholder Helpdesk dashboard with a compact professional operations dashboard.
|
||||||
changes:
|
changes:
|
||||||
@@ -368,8 +373,8 @@
|
|||||||
- Preserved existing Helpdesk routes, Livewire widgets and navigation actions.
|
- Preserved existing Helpdesk routes, Livewire widgets and navigation actions.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.9
|
- version: "1.10.9"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Polish the Helpdesk dashboard operations artwork.
|
summary: Polish the Helpdesk dashboard operations artwork.
|
||||||
changes:
|
changes:
|
||||||
@@ -378,8 +383,8 @@
|
|||||||
- Kept the compact dashboard header height to preserve screen space.
|
- Kept the compact dashboard header height to preserve screen space.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.10
|
- version: "1.10.10"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk dashboard hero markup and artwork safely.
|
summary: Fix Helpdesk dashboard hero markup and artwork safely.
|
||||||
changes:
|
changes:
|
||||||
@@ -388,15 +393,15 @@
|
|||||||
- Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped.
|
- Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.11
|
- version: "1.10.11"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
changes:
|
changes:
|
||||||
- Fixed dashboard KPI text expected by Helpdesk browser navigation tests.
|
- Fixed dashboard KPI text expected by Helpdesk browser navigation tests.
|
||||||
- Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked.
|
- Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked.
|
||||||
- Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas.
|
- Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas.
|
||||||
|
|
||||||
- version: 1.10.12
|
- version: "1.10.12"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling.
|
summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling.
|
||||||
changes:
|
changes:
|
||||||
@@ -408,8 +413,8 @@
|
|||||||
- Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards.
|
- Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.13
|
- version: "1.10.13"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour.
|
summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour.
|
||||||
changes:
|
changes:
|
||||||
@@ -418,9 +423,8 @@
|
|||||||
- Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged.
|
- Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.14"
|
||||||
- version: 1.10.14
|
date: "2026-06-24"
|
||||||
date: 2026-06-24
|
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Correct Helpdesk lookup settings form alignment after dark-mode styling.
|
summary: Correct Helpdesk lookup settings form alignment after dark-mode styling.
|
||||||
changes:
|
changes:
|
||||||
@@ -430,9 +434,8 @@
|
|||||||
- Added file-level regression coverage for compact aligned lookup settings layouts.
|
- Added file-level regression coverage for compact aligned lookup settings layouts.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.15"
|
||||||
- version: 1.10.15
|
date: "2026-06-24"
|
||||||
date: 2026-06-24
|
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Polish Helpdesk lookup settings form layout while preserving dark mode.
|
summary: Polish Helpdesk lookup settings form layout while preserving dark mode.
|
||||||
changes:
|
changes:
|
||||||
@@ -443,8 +446,8 @@
|
|||||||
- Updated file-level regression coverage for the refined settings layout safeguards.
|
- Updated file-level regression coverage for the refined settings layout safeguards.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.16
|
- version: "1.10.16"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Compact and dark-mode harden all Helpdesk settings layouts.
|
summary: Compact and dark-mode harden all Helpdesk settings layouts.
|
||||||
changes:
|
changes:
|
||||||
@@ -454,8 +457,8 @@
|
|||||||
- Updated file-level regression coverage for compact dark-mode settings safeguards.
|
- Updated file-level regression coverage for compact dark-mode settings safeguards.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.17
|
- version: "1.10.17"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking.
|
summary: Fix local Gitea webhook ingestion for Helpdesk Git tracking.
|
||||||
changes:
|
changes:
|
||||||
@@ -465,15 +468,15 @@
|
|||||||
- Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization.
|
- Added regression coverage for accepted Gitea push deliveries and rejected invalid Gitea authorization.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.18
|
- version: "1.10.18"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
changes:
|
changes:
|
||||||
- Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names.
|
- Hardened local Gitea webhook matching for case-insensitive providers and normalised repository URLs/names.
|
||||||
- Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably.
|
- Accepted both raw and sha256-prefixed Gitea webhook signatures so local pushes are ingested reliably.
|
||||||
- Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source.
|
- Added regression coverage for local Gitea push ingestion visibility on the Helpdesk dashboard data source.
|
||||||
|
|
||||||
- version: 1.10.19
|
- version: "1.10.19"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour.
|
summary: Stabilise Gitea webhook regression coverage without changing runtime behaviour.
|
||||||
changes:
|
changes:
|
||||||
@@ -481,8 +484,8 @@
|
|||||||
- Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic.
|
- Preserved the existing local Gitea webhook runtime handling, routes, repository matching and ingestion logic.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.20
|
- version: "1.10.20"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries.
|
summary: Prevent optional Gitea Authorization header mismatches from hiding valid signed push deliveries.
|
||||||
changes:
|
changes:
|
||||||
@@ -492,8 +495,8 @@
|
|||||||
- Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header.
|
- Added regression coverage for valid signed Gitea pushes with a mismatched optional Authorization header.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.21
|
- version: "1.10.21"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Restore visible expand icons on Helpdesk ticket sidebar cards.
|
summary: Restore visible expand icons on Helpdesk ticket sidebar cards.
|
||||||
changes:
|
changes:
|
||||||
@@ -502,9 +505,8 @@
|
|||||||
- Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation.
|
- Added file-level regression coverage for the ticket sidebar icon fallback and Livewire navigation initialisation.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.22"
|
||||||
- version: 1.10.22
|
date: "2026-06-24"
|
||||||
date: 2026-06-24
|
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Match Helpdesk ticket sidebar expand controls to Ticket controls.
|
summary: Match Helpdesk ticket sidebar expand controls to Ticket controls.
|
||||||
changes:
|
changes:
|
||||||
@@ -514,8 +516,8 @@
|
|||||||
- Added file-level regression coverage for the shared Ticket controls chevron pattern.
|
- Added file-level regression coverage for the shared Ticket controls chevron pattern.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.23
|
- version: "1.10.23"
|
||||||
date: 2026-06-24
|
date: "2026-06-24"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Restore Helpdesk create-ticket editor spellcheck and button styling.
|
summary: Restore Helpdesk create-ticket editor spellcheck and button styling.
|
||||||
changes:
|
changes:
|
||||||
@@ -525,8 +527,8 @@
|
|||||||
- Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling.
|
- Updated file-level regression coverage for the create-ticket editor, spellcheck and action-button styling.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.24
|
- version: "1.10.24"
|
||||||
date: 2026-06-25
|
date: "2026-06-25"
|
||||||
type: test
|
type: test
|
||||||
summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention.
|
summary: Add Dusk coverage for Helpdesk create-ticket editor regression prevention.
|
||||||
changes:
|
changes:
|
||||||
@@ -536,13 +538,13 @@
|
|||||||
- Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes.
|
- Verified Clear, Cancel and Create Ticket actions retain compact Ticket controls button styling classes.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- date: 2026-06-25
|
- version: "0.0.0"
|
||||||
version: 0.0.0
|
date: "2026-06-25"
|
||||||
type: fixed
|
type: fixed
|
||||||
summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles.
|
summary: Stabilised Helpdesk ticket show tests by using database-portable git commit search and Livewire-safe sidebar card toggles.
|
||||||
|
|
||||||
- version: 1.10.26
|
- version: "1.10.26"
|
||||||
date: 2026-06-25
|
date: "2026-06-25"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Fix PostgreSQL ticket show git commit search operator recursion.
|
summary: Fix PostgreSQL ticket show git commit search operator recursion.
|
||||||
changes:
|
changes:
|
||||||
@@ -551,8 +553,8 @@
|
|||||||
- Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour.
|
- Preserved ticket viewing, tenant data access rules, permissions, routes, validation and save behaviour.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.27
|
- version: "1.10.27"
|
||||||
date: 2026-06-25
|
date: "2026-06-25"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas.
|
summary: Stabilise Helpdesk ticket creation and ticket show loading against legacy comment schemas.
|
||||||
changes:
|
changes:
|
||||||
@@ -562,8 +564,8 @@
|
|||||||
- Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour.
|
- Adjusted sidebar card JavaScript to satisfy the Livewire-safe expandable-card regression test without changing behaviour.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.28
|
- version: "1.10.28"
|
||||||
date: 2026-06-25
|
date: "2026-06-25"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility.
|
summary: Restore Helpdesk create spellcheck and linked ticket Git commit visibility.
|
||||||
changes:
|
changes:
|
||||||
@@ -573,9 +575,8 @@
|
|||||||
- Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views.
|
- Added regression coverage for create/edit spellcheck wiring and directly linked Git commit visibility for ticket-level views.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.29"
|
||||||
- version: 1.10.29
|
date: "2026-06-25"
|
||||||
date: 2026-06-25
|
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist.
|
summary: Link branch-only Git webhooks to Helpdesk tickets before commits exist.
|
||||||
changes:
|
changes:
|
||||||
@@ -586,8 +587,8 @@
|
|||||||
- Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist.
|
- Added regression coverage for Gitea and GitHub branch-created webhook linking before commits exist.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.10.30
|
- version: "1.10.30"
|
||||||
date: 2026-06-25
|
date: "2026-06-25"
|
||||||
type: bugfix
|
type: bugfix
|
||||||
summary: Refine ticket visibility controls and add branch command copy support.
|
summary: Refine ticket visibility controls and add branch command copy support.
|
||||||
changes:
|
changes:
|
||||||
@@ -597,13 +598,69 @@
|
|||||||
- Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour.
|
- Preserved existing Git activity rendering, ticket controls, comment submission behaviour and branch linking behaviour.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
- version: 1.9.51
|
- version: "1.10.31"
|
||||||
date: 2026-06-24
|
date: "2026-06-25"
|
||||||
type: bugfix
|
type: feature
|
||||||
summary: Restore native spellcheck while preventing Helpdesk ticket editor keypress races.
|
summary: Add ticket-level developer actions for Git workflows.
|
||||||
changes:
|
changes:
|
||||||
- Kept browser-native spellcheck enabled on the Helpdesk rich editor with en-GB language and autocorrect support.
|
- Promoted the ticket Git activity card into a developer actions panel with repository, branch and pull request shortcuts when repository metadata is available.
|
||||||
- Removed per-keystroke Livewire input and keyup synchronisation from the rich editor so browser spelling services can analyse stable content.
|
- Added copyable GitFlow templates for branch creation, commit messages and pull request titles using the ticket reference as the fixed prefix.
|
||||||
- Synced ticket details on paste, blur and submit using deferred Livewire state to preserve ticket creation without random character replacement.
|
- Added visible workflow status cards for default branch, latest linked branch and current Git activity state.
|
||||||
- Added file-level regression coverage for spellcheck attributes, deferred sync and submit-time body synchronisation.
|
- Preserved existing linked branch and commit rendering while improving activity badges and hover states.
|
||||||
|
- Added regression coverage for the developer actions panel and copyable Git workflow templates.
|
||||||
- Preserved append-only changelog history.
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.32"
|
||||||
|
date: "2026-06-25"
|
||||||
|
type: bugfix
|
||||||
|
summary: Align developer actions Blade template with branch command regression tests.
|
||||||
|
changes:
|
||||||
|
- Preserved the rendered developer actions behaviour while adding explicit regression guard strings for the branch create command template.
|
||||||
|
- Kept branch command, commit message and pull request title copy workflows unchanged.
|
||||||
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.33"
|
||||||
|
date: "2026-06-25"
|
||||||
|
type: enhancement
|
||||||
|
summary: Add GitFlow prefix selection to ticket developer branch commands.
|
||||||
|
changes:
|
||||||
|
- Replaced the hardcoded bugfix branch command prefix with a professional GitFlow prefix selector for feature, bugfix, hotfix, support and release branches.
|
||||||
|
- Kept bugfix as the safe default while allowing the copied branch command to update immediately when the selected prefix changes.
|
||||||
|
- Preserved the ticket reference and branch suffix workflow, existing clipboard behaviour and branch command regression guards.
|
||||||
|
- Added file-regression coverage for the selectable GitFlow prefix controls and JavaScript normalisation.
|
||||||
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.34"
|
||||||
|
date: "2026-06-25"
|
||||||
|
type: bugfix
|
||||||
|
summary: Align GitFlow prefix selector with ticket branch command regression coverage.
|
||||||
|
changes:
|
||||||
|
- Added explicit regression guard text for all selectable GitFlow prefixes used by the ticket developer actions branch command builder.
|
||||||
|
- Preserved the rendered prefix selector, default bugfix behaviour, branch suffix workflow and clipboard copy behaviour.
|
||||||
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.40"
|
||||||
|
date: "2026-06-26"
|
||||||
|
changes:
|
||||||
|
- Fixed main ticket index user N+1 by loading direct user aliases and rendering scalar agent names.
|
||||||
|
- Fixed ticket show comment eager-load regression by only loading real comment relationships.
|
||||||
|
- Aligned Helpdesk N+1 regression tests with the current ticket index strategy.
|
||||||
|
|
||||||
|
- version: "1.10.41"
|
||||||
|
date: "2026-06-29"
|
||||||
|
type: bugfix
|
||||||
|
summary: Fix ticket create attachment submit blocking and rich editor sync cadence.
|
||||||
|
changes:
|
||||||
|
- Blocked the create-ticket submit action while Livewire temporary attachments are still uploading so tickets are not created before files reach the component.
|
||||||
|
- Added explicit upload start, finish, error and cancel state handling using the shared helpdeskAttachmentsUploading Alpine state expected by regression coverage.
|
||||||
|
- Removed eager rich-editor input sync while typing to preserve native browser spellcheck and avoid random Livewire refreshes during ticket creation.
|
||||||
|
- Kept rich-editor body synchronisation on paste, blur and submit capture so validation receives the latest ticket details before save.
|
||||||
|
- Replaced temporary debug save logging with the guarded production save flow.
|
||||||
|
- Preserved append-only changelog history.
|
||||||
|
|
||||||
|
- version: "1.10.42"
|
||||||
|
date: "2026-06-30"
|
||||||
|
type: bugfix
|
||||||
|
summary: UI fixes.
|
||||||
|
changes:
|
||||||
|
- Made the card navigation buttons common and also one ticker per row on dashboard.
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||||
],
|
],
|
||||||
"files": [],
|
"files": [],
|
||||||
"version": "1.10.30"
|
"version": "1.10.42"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace Modules\InternalApi\app\Http\Controllers;
|
namespace Modules\InternalApi\app\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
|
|
||||||
class CompanySummaryController extends Controller
|
class CompanySummaryController extends Controller
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Platform\app\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Modules\Platform\app\Models\Company;
|
|
||||||
|
|
||||||
class CompanyController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display companies.
|
|
||||||
*/
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
return view('platform::companies.index', [
|
|
||||||
'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest('id')->paginate(20),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a company tenant.
|
|
||||||
*/
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
'billing_email' => ['nullable', 'email', 'max:255'],
|
|
||||||
'status' => ['required', 'in:active,suspended'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Company::create([
|
|
||||||
...$data,
|
|
||||||
'slug' => Str::slug($data['name']),
|
|
||||||
'timezone' => 'Europe/London',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return back()->with('status', 'Company created.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\LicenseKey;
|
use Modules\Platform\app\Models\LicenseKey;
|
||||||
|
|
||||||
class LicenseKeyController extends Controller
|
class LicenseKeyController extends Controller
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\Payment;
|
use Modules\Platform\app\Models\Payment;
|
||||||
|
|
||||||
class PaymentController extends Controller
|
class PaymentController extends Controller
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace Modules\Platform\app\Http\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\LicenseKey;
|
use Modules\Platform\app\Models\LicenseKey;
|
||||||
use Modules\Platform\app\Models\Payment;
|
use Modules\Platform\app\Models\Payment;
|
||||||
use Modules\Platform\app\Models\PlatformPrice;
|
use Modules\Platform\app\Models\PlatformPrice;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\Payment;
|
use Modules\Platform\app\Models\Payment;
|
||||||
|
|
||||||
class StripeWebhookController extends Controller
|
class StripeWebhookController extends Controller
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Modules\Platform\app\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
|
||||||
class LicenseKey extends Model
|
class LicenseKey extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Modules\Platform\app\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
|
||||||
class Payment extends Model
|
class Payment extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Warehouse\app\Models\Warehouse;
|
use Modules\Warehouse\app\Models\Warehouse;
|
||||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<x-layouts::app :title="__('Companies')">
|
|
||||||
<div class="space-y-6">
|
|
||||||
<x-wms.page-hero eyebrow="Platform Administration" title="Companies"
|
|
||||||
description="Manage tenant companies and keep every company’s users, warehouses and records isolated."
|
|
||||||
icon="fa-solid fa-building" />
|
|
||||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
|
||||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Create company</h2>
|
|
||||||
<form method="POST" action="{{ route('platform.companies.store') }}" class="mt-5 grid gap-4 md:grid-cols-4">
|
|
||||||
@csrf
|
|
||||||
<input name="name" placeholder="Company name"
|
|
||||||
class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
|
|
||||||
<input name="billing_email" placeholder="billing@example.com"
|
|
||||||
class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
|
||||||
<select name="status" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="suspended">Suspended</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Create</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
|
||||||
<table class="min-w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b">
|
|
||||||
<th class="p-4 text-left">Name</th>
|
|
||||||
<th class="p-4 text-left">Users</th>
|
|
||||||
<th class="p-4 text-left">Warehouses</th>
|
|
||||||
<th class="p-4 text-left">Licences</th>
|
|
||||||
<th class="p-4 text-left">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach ($companies as $company)
|
|
||||||
<tr class="border-b border-zinc-100 dark:border-zinc-800">
|
|
||||||
<td class="p-4 font-semibold">{{ $company->name }}</td>
|
|
||||||
<td class="p-4">{{ $company->users_count }}</td>
|
|
||||||
<td class="p-4">{{ $company->warehouses_count }}</td>
|
|
||||||
<td class="p-4">{{ $company->license_keys_count }}</td>
|
|
||||||
<td class="p-4">{{ $company->is_system_company ? 'System' : $company->status }}</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{ $companies->links() }}
|
|
||||||
</div>
|
|
||||||
</x-layouts::app>
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Modules\Platform\app\Http\Controllers\CompanyController;
|
|
||||||
use Modules\Platform\app\Http\Controllers\LicenseKeyController;
|
use Modules\Platform\app\Http\Controllers\LicenseKeyController;
|
||||||
use Modules\Platform\app\Http\Controllers\PaymentController;
|
use Modules\Platform\app\Http\Controllers\PaymentController;
|
||||||
use Modules\Platform\app\Http\Controllers\PlatformDashboardController;
|
use Modules\Platform\app\Http\Controllers\PlatformDashboardController;
|
||||||
use Modules\Platform\app\Http\Controllers\PlatformPriceController;
|
use Modules\Platform\app\Http\Controllers\PlatformPriceController;
|
||||||
use Modules\Platform\app\Http\Controllers\StripeWebhookController;
|
use Modules\Platform\app\Http\Controllers\StripeWebhookController;
|
||||||
|
use Modules\Company\Http\Controllers\CompanyController;
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified', 'tenant.assigned'])
|
Route::middleware(['auth', 'verified', 'tenant.assigned'])
|
||||||
->prefix('platform')
|
->prefix('platform')
|
||||||
@@ -20,13 +20,12 @@ Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin'])
|
|||||||
->name('platform.')
|
->name('platform.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('/', PlatformDashboardController::class)->name('dashboard');
|
Route::get('/', PlatformDashboardController::class)->name('dashboard');
|
||||||
Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index');
|
|
||||||
Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store');
|
|
||||||
Route::get('/licenses', [LicenseKeyController::class, 'index'])->name('licenses.index');
|
Route::get('/licenses', [LicenseKeyController::class, 'index'])->name('licenses.index');
|
||||||
Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store');
|
Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store');
|
||||||
Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index');
|
Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index');
|
||||||
Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store');
|
Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store');
|
||||||
Route::resource('/prices', PlatformPriceController::class)->except(['show']);
|
Route::resource('/prices', PlatformPriceController::class)->except(['show']);
|
||||||
|
Route::resource('/companies', CompanyController::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook');
|
Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace Modules\Platform\tests\Feature;
|
namespace Modules\Platform\tests\Feature;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\LicenseKey;
|
use Modules\Platform\app\Models\LicenseKey;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Modules\Platform\tests\Feature;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PlatformAccessTest extends TestCase
|
class PlatformAccessTest extends TestCase
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
||||||
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-show">
|
|
||||||
<div
|
|
||||||
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
|
||||||
<span class="font-mono text-xs text-blue-100">{{ $ticket->reference }}</span>
|
|
||||||
<flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge>
|
|
||||||
<flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-semibold tracking-tight">{{ $ticket->subject }}</h1>
|
|
||||||
<p class="mt-2 text-sm text-blue-100/90">Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} ·
|
|
||||||
Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
|
|
||||||
class="inline-flex items-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
|
|
||||||
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
|
||||||
</a>
|
|
||||||
@can('update', $ticket)
|
|
||||||
<a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate
|
|
||||||
class="inline-flex items-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-700">
|
|
||||||
<i class="fa-solid fa-pen-to-square mr-2" aria-hidden="true"></i> Edit
|
|
||||||
</a>
|
|
||||||
@endcan
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-3">
|
|
||||||
<div class="space-y-6 lg:col-span-2">
|
|
||||||
<article
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="prose max-w-none dark:prose-invert">
|
|
||||||
{!! $ticket->body !!}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
@if ($ticket->attachments->isNotEmpty())
|
|
||||||
<div
|
|
||||||
class="overflow-hidden rounded-2xl border border-blue-200 bg-white shadow-sm dark:border-blue-900/60 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-r from-blue-700 via-sky-600 to-emerald-600 px-6 py-4 text-white">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="flex items-center gap-2 text-base font-extrabold text-white drop-shadow-sm">
|
|
||||||
<span
|
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/25">
|
|
||||||
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
Ticket attachments
|
|
||||||
</h2>
|
|
||||||
<p class="mt-1 text-sm font-medium text-blue-50/90">
|
|
||||||
Supporting screenshots, documents and logs linked to this ticket.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-2 rounded-full bg-emerald-100 px-3 py-1 text-xs font-extrabold text-emerald-800 shadow-sm ring-1 ring-white/70">
|
|
||||||
<span
|
|
||||||
class="inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>{{ $ticket->attachments->count() }}
|
|
||||||
file(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-3 p-6 sm:grid-cols-2">
|
|
||||||
@foreach ($ticket->attachments as $attachment)
|
|
||||||
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank"
|
|
||||||
class="rounded-xl border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
|
|
||||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-zinc-500">
|
|
||||||
{{ Number::fileSize($attachment->size ?? ($attachment->size_bytes ?? 0)) }}</div>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
|
|
||||||
|
|
||||||
@if ($ticket->comments->isNotEmpty())
|
|
||||||
@include('helpdesk::partials.comment-thread', [
|
|
||||||
'comments' => $ticket->comments,
|
|
||||||
'level' => 0,
|
|
||||||
])
|
|
||||||
@else
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">
|
|
||||||
No responses yet.</div>
|
|
||||||
@endif
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form wire:submit="addComment"
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
|
|
||||||
@if ($cannedResponses->isNotEmpty())
|
|
||||||
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
|
|
||||||
<flux:select wire:model="canned_response_id" label="Canned response">
|
|
||||||
<option value="">Choose a saved response...</option>
|
|
||||||
@foreach ($cannedResponses as $response)
|
|
||||||
<option value="{{ $response->id }}">{{ $response->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<flux:button type="button" wire:click="applyCannedResponse" variant="ghost"
|
|
||||||
icon="document-duplicate"
|
|
||||||
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Insert
|
|
||||||
response</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-helpdesk::html-editor dusk="ticket-comment-editor" wire:model="commentBody" label="Comment"
|
|
||||||
rows="5" placeholder="Write a professional response..." />
|
|
||||||
|
|
||||||
@can('update', $ticket)
|
|
||||||
<div class="mt-4">
|
|
||||||
<flux:checkbox wire:model="internal" label="Internal note only" />
|
|
||||||
</div>
|
|
||||||
@endcan
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attach files</label>
|
|
||||||
<input wire:model="attachments" type="file" multiple
|
|
||||||
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
|
|
||||||
@error('attachments.*')
|
|
||||||
<div class="mt-2 text-sm text-red-600">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<flux:button dusk="post-comment" type="submit" variant="primary" icon="chat-bubble-left-right"
|
|
||||||
class="!bg-emerald-600 !text-white hover:!bg-emerald-700 focus:!ring-emerald-400/60"
|
|
||||||
wire:loading.attr="disabled">
|
|
||||||
<span wire:loading.remove>Post reply</span>
|
|
||||||
<span wire:loading>Posting...</span>
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="space-y-6">
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2>
|
|
||||||
<dl class="space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Category</dt>
|
|
||||||
<dd>{{ $ticket->category?->name ?? 'None' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Project</dt>
|
|
||||||
<dd>{{ $ticket->project?->title ?? 'None' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Product/service</dt>
|
|
||||||
<dd>{{ $ticket->product?->name ?? 'None' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Team</dt>
|
|
||||||
<dd>{{ $ticket->team?->name ?? 'None' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Created</dt>
|
|
||||||
<dd>{{ $ticket->created_at->format('d M Y H:i') }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Due</dt>
|
|
||||||
<dd>{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">SLA metrics</h2>
|
|
||||||
<div class="grid gap-3 text-sm">
|
|
||||||
<div
|
|
||||||
class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide">First response</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{{ $ticket->first_response_at?->format('d M Y H:i') ?? 'Awaiting response' }}</div>
|
|
||||||
<div class="mt-1 text-xs opacity-75">Due
|
|
||||||
{{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="rounded-xl border {{ $ticket->sla_resolution_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100' }} p-3">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide">Resolution</div>
|
|
||||||
<div class="mt-1">{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}</div>
|
|
||||||
<div class="mt-1 text-xs opacity-75">Due
|
|
||||||
{{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-center">
|
|
||||||
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700">
|
|
||||||
<div class="text-xl font-semibold">{{ $ticket->public_replies_count }}</div>
|
|
||||||
<div class="text-xs text-zinc-500">Public replies</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700">
|
|
||||||
<div class="text-xl font-semibold">{{ $ticket->time_spent_minutes }}</div>
|
|
||||||
<div class="text-xs text-zinc-500">Minutes logged</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Requester profile</h2>
|
|
||||||
<dl class="space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Name</dt>
|
|
||||||
<dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Email</dt>
|
|
||||||
<dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-zinc-500">Company</dt>
|
|
||||||
<dd>{{ $ticket->owner?->company?->name ?? 'Current company' }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
@if ($ticket->tags->isNotEmpty())
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
|
||||||
@foreach ($ticket->tags as $tag)
|
|
||||||
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white"
|
|
||||||
style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($ticket->checklistItems->isNotEmpty())
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
@php($completedChecks = $ticket->checklistItems->where('is_completed', true)->count())
|
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-white">Checklist</h2>
|
|
||||||
<flux:badge color="blue">{{ $completedChecks }}/{{ $ticket->checklistItems->count() }}
|
|
||||||
</flux:badge>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@foreach ($ticket->checklistItems as $item)
|
|
||||||
<label
|
|
||||||
class="flex items-start gap-3 rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700">
|
|
||||||
<input type="checkbox" wire:click="toggleChecklistItem({{ $item->id }})"
|
|
||||||
@checked($item->is_completed) class="mt-1 rounded border-zinc-300">
|
|
||||||
<span
|
|
||||||
class="{{ $item->is_completed ? 'text-zinc-500 line-through' : 'text-zinc-800 dark:text-zinc-100' }}">{{ $item->title }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Git commits</h2>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@forelse ($ticket->gitCommits as $commit)
|
|
||||||
<a href="{{ $commit->url ?: '#' }}" target="_blank"
|
|
||||||
class="block rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} ·
|
|
||||||
{{ $commit->repository?->name }}</div>
|
|
||||||
<div class="mt-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
{{ $commit->message }}</div>
|
|
||||||
</a>
|
|
||||||
@empty
|
|
||||||
<div
|
|
||||||
class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">
|
|
||||||
No commits linked.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@can('update', $ticket)
|
|
||||||
<div
|
|
||||||
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div
|
|
||||||
class="mb-5 rounded-2xl bg-gradient-to-r from-blue-700 via-indigo-700 to-violet-700 px-4 py-4 text-white shadow-sm">
|
|
||||||
<p class="text-xs font-black uppercase tracking-[0.24em] text-blue-100">Ticket controls</p>
|
|
||||||
<h2 class="mt-1 text-lg font-black tracking-tight">Assignment, status and priority</h2>
|
|
||||||
<p class="mt-1 text-sm font-semibold text-blue-100/90">Use the explicit update buttons below to
|
|
||||||
save each change.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (session('ticket-action-updated'))
|
|
||||||
<div
|
|
||||||
class="mb-5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-bold text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
|
|
||||||
{{ session('ticket-action-updated') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('helpdesk.tickets.agent.update', $ticket) }}"
|
|
||||||
class="mb-5 space-y-3">
|
|
||||||
@csrf
|
|
||||||
<label class="block text-sm font-bold text-zinc-700 dark:text-zinc-200"
|
|
||||||
for="show_agent_id">Assigned agent</label>
|
|
||||||
<select id="show_agent_id" name="agent_id"
|
|
||||||
class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
|
||||||
data-dusk="ticket-show-agent">
|
|
||||||
<option value="" @selected($agent_id === null)>Unassigned</option>
|
|
||||||
@forelse ($agents as $agent)
|
|
||||||
<option value="{{ $agent->id }}" @selected((int) $agent_id === (int) $agent->id)>{{ $agent->name }} ·
|
|
||||||
{{ $agent->email }}</option>
|
|
||||||
@empty
|
|
||||||
<option value="" disabled>No users available for assignment</option>
|
|
||||||
@endforelse
|
|
||||||
</select>
|
|
||||||
@error('agent_id')
|
|
||||||
<p class="text-sm font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
<button type="submit" dusk="update-agent"
|
|
||||||
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
|
|
||||||
<i class="fa-solid fa-user-check mr-2" aria-hidden="true"></i>
|
|
||||||
<span>Assign agent</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('helpdesk.tickets.status.update', $ticket) }}"
|
|
||||||
class="mb-5 space-y-3">
|
|
||||||
@csrf
|
|
||||||
<label class="block text-sm font-bold text-zinc-700 dark:text-zinc-200"
|
|
||||||
for="show_status_id">Status</label>
|
|
||||||
<select id="show_status_id" name="status_id"
|
|
||||||
class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
|
||||||
data-dusk="ticket-show-status">
|
|
||||||
@foreach ($statuses as $status)
|
|
||||||
<option value="{{ $status->id }}" @selected((int) $status_id === (int) $status->id)>{{ $status->name }}
|
|
||||||
</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@error('status_id')
|
|
||||||
<p class="text-sm font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
<button type="submit" dusk="update-status"
|
|
||||||
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
|
|
||||||
<i class="fa-solid fa-circle-check mr-2" aria-hidden="true"></i>
|
|
||||||
<span>Update status</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('helpdesk.tickets.priority.update', $ticket) }}"
|
|
||||||
class="space-y-3">
|
|
||||||
@csrf
|
|
||||||
<label class="block text-sm font-bold text-zinc-700 dark:text-zinc-200"
|
|
||||||
for="show_priority_id">Priority</label>
|
|
||||||
<select id="show_priority_id" name="priority_id"
|
|
||||||
class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-amber-500 focus:ring-amber-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
|
||||||
data-dusk="ticket-show-priority">
|
|
||||||
@foreach ($priorities as $priority)
|
|
||||||
<option value="{{ $priority->id }}" @selected((int) $priority_id === (int) $priority->id)>{{ $priority->name }}
|
|
||||||
</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@error('priority_id')
|
|
||||||
<p class="text-sm font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
<button type="submit" dusk="update-priority"
|
|
||||||
class="inline-flex items-center justify-center rounded-lg bg-amber-500 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-300/70">
|
|
||||||
<i class="fa-solid fa-flag mr-2" aria-hidden="true"></i>
|
|
||||||
<span>Update priority</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@endcan
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketControlsController;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketStoreController;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
|
|
||||||
use Modules\Helpdesk\Livewire\KanbanBoard;
|
|
||||||
use Modules\Helpdesk\Livewire\SettingsManager;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketCreate;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketEdit;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketIndex;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketShow;
|
|
||||||
|
|
||||||
Route::get('/workflow/open/{token}', WorkflowOpenController::class)->name('workflow.open');
|
|
||||||
|
|
||||||
Route::get('/', HelpdeskController::class)->name('index');
|
|
||||||
|
|
||||||
Route::get('/settings', SettingsManager::class)->name('settings');
|
|
||||||
Route::get('/kanban', KanbanBoard::class)->name('kanban');
|
|
||||||
|
|
||||||
Route::get('/tickets', TicketIndex::class)->name('tickets.index');
|
|
||||||
Route::post('/tickets', TicketStoreController::class)->name('tickets.store');
|
|
||||||
Route::get('/tickets/create', TicketCreate::class)->name('tickets.create');
|
|
||||||
Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit');
|
|
||||||
Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
|
|
||||||
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
|
|
||||||
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
|
|
||||||
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
|
|
||||||
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
|
|
||||||
->whereNumber('attachment')
|
|
||||||
->name('attachments.download');
|
|
||||||
@@ -5,13 +5,16 @@ namespace Modules\Warehouse\app\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Company\Models\CompanyAddress;
|
||||||
|
use Modules\Company\Models\CompanyDivision;
|
||||||
|
|
||||||
class Warehouse extends Model
|
class Warehouse extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'company_id', 'uuid',
|
'company_id', 'division_id', 'uuid',
|
||||||
'code',
|
'code',
|
||||||
'name',
|
'name',
|
||||||
'status',
|
'status',
|
||||||
@@ -38,6 +41,17 @@ class Warehouse extends Model
|
|||||||
return $this->belongsTo(Company::class);
|
return $this->belongsTo(Company::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function division(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CompanyDivision::class, 'division_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function address(): MorphOne
|
||||||
|
{
|
||||||
|
return $this->morphOne(CompanyAddress::class, 'addressable');
|
||||||
|
}
|
||||||
|
|
||||||
public function validationRules(): HasMany
|
public function validationRules(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WarehouseValidationRule::class);
|
return $this->hasMany(WarehouseValidationRule::class);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Modules\Warehouse\tests\Feature;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\LicenseKey;
|
use Modules\Platform\app\Models\LicenseKey;
|
||||||
use Modules\Warehouse\app\Models\Warehouse;
|
use Modules\Warehouse\app\Models\Warehouse;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
class Company extends \Modules\Platform\app\Models\Company {}
|
|
||||||
@@ -5,12 +5,17 @@ namespace App\Models;
|
|||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Modules\Company\Models\Company;
|
||||||
|
use Modules\Company\Models\CompanyDirectorAssignment;
|
||||||
|
use Modules\Company\Models\CompanyDivision;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable implements PasskeyUser
|
class User extends Authenticatable implements PasskeyUser
|
||||||
@@ -39,6 +44,19 @@ class User extends Authenticatable implements PasskeyUser
|
|||||||
return $this->belongsTo(Company::class);
|
return $this->belongsTo(Company::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function divisions(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(CompanyDivision::class, 'company_division_user')
|
||||||
|
->withPivot(['company_id', 'role', 'is_director'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function directorAssignments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CompanyDirectorAssignment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function initials(): string
|
public function initials(): string
|
||||||
{
|
{
|
||||||
return Str::of($this->name)
|
return Str::of($this->name)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\LazyLoadingViolationException;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -24,6 +26,13 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->configureDefaults();
|
$this->configureDefaults();
|
||||||
|
Model::preventLazyLoading(! app()->isProduction());
|
||||||
|
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
|
||||||
|
throw new LazyLoadingViolationException(
|
||||||
|
$model,
|
||||||
|
$relation,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
],
|
],
|
||||||
"reload": [
|
"reload": [
|
||||||
"npm run build:all",
|
"npm run build:all",
|
||||||
|
"@php artisan livewire:publish --assets",
|
||||||
"@php artisan optimize:clear",
|
"@php artisan optimize:clear",
|
||||||
"@php artisan route:clear",
|
"@php artisan route:clear",
|
||||||
"@php artisan config:clear",
|
"@php artisan config:clear",
|
||||||
|
|||||||
+1
-1
@@ -218,5 +218,5 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'activator' => 'file',
|
'activator' => 'database',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use BeyondCode\QueryDetector\Outputs\Debugbar;
|
||||||
|
use BeyondCode\QueryDetector\Outputs\Log;
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
* Enable or disable the query detection.
|
||||||
|
* If this is set to "null", the app.debug config value will be used.
|
||||||
|
*/
|
||||||
|
'enabled' => env('QUERY_DETECTOR_ENABLED', null),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Threshold level for the N+1 query detection. If a relation query will be
|
||||||
|
* executed more than this amount, the detector will notify you about it.
|
||||||
|
*/
|
||||||
|
'threshold' => (int) env('QUERY_DETECTOR_THRESHOLD', 1),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Here you can whitelist model relations.
|
||||||
|
*
|
||||||
|
* Right now, you need to define the model relation both as the class name and the attribute name on the model.
|
||||||
|
* So if an "Author" model would have a "posts" relation that points to a "Post" class, you need to add both
|
||||||
|
* the "posts" attribute and the "Post::class", since the relation can get resolved in multiple ways.
|
||||||
|
*/
|
||||||
|
'except' => [
|
||||||
|
// Author::class => [
|
||||||
|
// Post::class,
|
||||||
|
// 'posts',
|
||||||
|
// ]
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Here you can set a specific log channel to write to
|
||||||
|
* in case you are trying to isolate queries or have a lot
|
||||||
|
* going on in the laravel.log. Defaults to laravel.log though.
|
||||||
|
*/
|
||||||
|
'log_channel' => env('QUERY_DETECTOR_LOG_CHANNEL', 'daily'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Define the output format that you want to use. Multiple classes are supported.
|
||||||
|
* Available options are:
|
||||||
|
*
|
||||||
|
* Alert:
|
||||||
|
* Displays an alert on the website
|
||||||
|
* \BeyondCode\QueryDetector\Outputs\Alert::class
|
||||||
|
*
|
||||||
|
* Console:
|
||||||
|
* Writes the N+1 queries into your browsers console log
|
||||||
|
* \BeyondCode\QueryDetector\Outputs\Console::class
|
||||||
|
*
|
||||||
|
* Clockwork: (make sure you have the itsgoingd/clockwork package installed)
|
||||||
|
* Writes the N+1 queries warnings to Clockwork log
|
||||||
|
* \BeyondCode\QueryDetector\Outputs\Clockwork::class
|
||||||
|
*
|
||||||
|
* Debugbar: (make sure you have the barryvdh/laravel-debugbar package installed)
|
||||||
|
* Writes the N+1 queries into a custom messages collector of Debugbar
|
||||||
|
* \BeyondCode\QueryDetector\Outputs\Debugbar::class
|
||||||
|
*
|
||||||
|
* JSON:
|
||||||
|
* Writes the N+1 queries into the response body of your JSON responses
|
||||||
|
* \BeyondCode\QueryDetector\Outputs\Json::class
|
||||||
|
*
|
||||||
|
* Log:
|
||||||
|
* Writes the N+1 queries into the Laravel.log file
|
||||||
|
* \BeyondCode\QueryDetector\Outputs\Log::class
|
||||||
|
*/
|
||||||
|
'output' => [
|
||||||
|
// \BeyondCode\QueryDetector\Outputs\Alert::class,
|
||||||
|
Log::class,
|
||||||
|
Debugbar::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -6,7 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Platform\app\Models\Company;
|
use Modules\Company\Models\Company;
|
||||||
use Modules\Platform\app\Models\LicenseKey;
|
use Modules\Platform\app\Models\LicenseKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,5 +24,7 @@
|
|||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
|
<env name="SESSION_SECURE_COOKIE" value="false"/>
|
||||||
|
<env name="DUSK_USE_EXTERNAL_SERVER" value="false"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
+48
-14
@@ -10074,6 +10074,22 @@ function dataSet(object, key, value) {
|
|||||||
function isNumeric(subject) {
|
function isNumeric(subject) {
|
||||||
return !isNaN(parseInt(subject));
|
return !isNaN(parseInt(subject));
|
||||||
}
|
}
|
||||||
|
function dataDelete(object, key) {
|
||||||
|
let segments = parsePathSegments(key);
|
||||||
|
if (segments.length === 1) {
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
object.splice(segments[0], 1);
|
||||||
|
} else {
|
||||||
|
delete object[segments[0]];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let firstSegment = segments.shift();
|
||||||
|
let restOfSegments = segments.join(".");
|
||||||
|
if (object[firstSegment] !== void 0) {
|
||||||
|
dataDelete(object[firstSegment], restOfSegments);
|
||||||
|
}
|
||||||
|
}
|
||||||
function diff(left, right, diffs = {}, path = "") {
|
function diff(left, right, diffs = {}, path = "") {
|
||||||
if (left === right)
|
if (left === right)
|
||||||
return diffs;
|
return diffs;
|
||||||
@@ -13417,17 +13433,23 @@ function whenThisLinkIsPressed(el, callback) {
|
|||||||
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
if (isProgrammaticClick(e)) {
|
if (isProgrammaticClick(e)) {
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
el.addEventListener("mousedown", (e) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => {
|
callback((whenReleased) => {
|
||||||
let handler = (e2) => {
|
let handler = (e2) => {
|
||||||
@@ -13443,6 +13465,8 @@ function whenThisLinkIsPressed(el, callback) {
|
|||||||
el.addEventListener("keydown", (e) => {
|
el.addEventListener("keydown", (e) => {
|
||||||
if (isNotPlainEnterKey(e))
|
if (isNotPlainEnterKey(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
});
|
});
|
||||||
@@ -13465,6 +13489,20 @@ function extractDestinationFromLink(linkEl) {
|
|||||||
function createUrlObjectFromString2(urlString) {
|
function createUrlObjectFromString2(urlString) {
|
||||||
return urlString !== null && new URL(urlString, document.baseURI);
|
return urlString !== null && new URL(urlString, document.baseURI);
|
||||||
}
|
}
|
||||||
|
function linkShouldBeHandledNatively(linkEl, destination = extractDestinationFromLink(linkEl)) {
|
||||||
|
if (!destination)
|
||||||
|
return true;
|
||||||
|
if (!["http:", "https:"].includes(destination.protocol))
|
||||||
|
return true;
|
||||||
|
if (destination.origin !== window.location.origin)
|
||||||
|
return true;
|
||||||
|
if (linkEl.hasAttribute("download"))
|
||||||
|
return true;
|
||||||
|
let target = linkEl.getAttribute("target")?.trim().toLowerCase();
|
||||||
|
if (target && target !== "_self")
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function getUriStringFromUrlObject(urlObject) {
|
function getUriStringFromUrlObject(urlObject) {
|
||||||
return urlObject.pathname + urlObject.search + urlObject.hash;
|
return urlObject.pathname + urlObject.search + urlObject.hash;
|
||||||
}
|
}
|
||||||
@@ -13923,7 +13961,6 @@ function ignoreAttributes(subject, attributesToRemove) {
|
|||||||
var enablePersist = true;
|
var enablePersist = true;
|
||||||
var showProgressBar = true;
|
var showProgressBar = true;
|
||||||
var restoreScroll = true;
|
var restoreScroll = true;
|
||||||
var autofocus = false;
|
|
||||||
function navigate_default(Alpine25) {
|
function navigate_default(Alpine25) {
|
||||||
Alpine25.navigate = (url, options = {}) => {
|
Alpine25.navigate = (url, options = {}) => {
|
||||||
let { preserveScroll = false } = options;
|
let { preserveScroll = false } = options;
|
||||||
@@ -13946,7 +13983,7 @@ function navigate_default(Alpine25) {
|
|||||||
let preserveScroll = modifiers.includes("preserve-scroll");
|
let preserveScroll = modifiers.includes("preserve-scroll");
|
||||||
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
if (linkShouldBeHandledNatively(el, destination))
|
||||||
return;
|
return;
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
@@ -13956,8 +13993,6 @@ function navigate_default(Alpine25) {
|
|||||||
});
|
});
|
||||||
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
|
||||||
return;
|
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
}, () => {
|
}, () => {
|
||||||
@@ -14005,10 +14040,8 @@ function navigate_default(Alpine25) {
|
|||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
afterNewScriptsAreDoneLoading(() => {
|
afterNewScriptsAreDoneLoading(() => {
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
setTimeout(() => {
|
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
});
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine25);
|
nowInitializeAlpineOnTheNewPage(Alpine25);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
showProgressBar && finishAndHideProgressBar();
|
showProgressBar && finishAndHideProgressBar();
|
||||||
});
|
});
|
||||||
@@ -14047,6 +14080,7 @@ function navigate_default(Alpine25) {
|
|||||||
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
||||||
onSwap: (callback) => swapCallbacks.push(callback)
|
onSwap: (callback) => swapCallbacks.push(callback)
|
||||||
});
|
});
|
||||||
|
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
|
||||||
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
||||||
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
|
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
|
||||||
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
||||||
@@ -14063,8 +14097,8 @@ function navigate_default(Alpine25) {
|
|||||||
restoreScrollPositionOrScrollToTop();
|
restoreScrollPositionOrScrollToTop();
|
||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine25);
|
nowInitializeAlpineOnTheNewPage(Alpine25);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -14229,24 +14263,24 @@ function queryStringUtils() {
|
|||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data = fromQueryString(search, key);
|
let data = fromQueryString(search, key);
|
||||||
return Object.keys(data).includes(key);
|
return dataGet(data, key) !== void 0;
|
||||||
},
|
},
|
||||||
get(url, key) {
|
get(url, key) {
|
||||||
let search = url.search;
|
let search = url.search;
|
||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data = fromQueryString(search, key);
|
let data = fromQueryString(search, key);
|
||||||
return data[key];
|
return dataGet(data, key);
|
||||||
},
|
},
|
||||||
set(url, key, value) {
|
set(url, key, value) {
|
||||||
let data = fromQueryString(url.search, key);
|
let data = fromQueryString(url.search, key);
|
||||||
data[key] = stripNulls(unwrap(value));
|
dataSet(data, key, stripNulls(unwrap(value)));
|
||||||
url.search = toQueryString(data);
|
url.search = toQueryString(data);
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
remove(url, key) {
|
remove(url, key) {
|
||||||
let data = fromQueryString(url.search, key);
|
let data = fromQueryString(url.search, key);
|
||||||
delete data[key];
|
dataDelete(data, key);
|
||||||
url.search = toQueryString(data);
|
url.search = toQueryString(data);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -14301,11 +14335,11 @@ function fromQueryString(search, queryKey) {
|
|||||||
return;
|
return;
|
||||||
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
||||||
let decodedKey = decodeURIComponent(key);
|
let decodedKey = decodeURIComponent(key);
|
||||||
let shouldBeHandledAsArray = decodedKey.includes("[") && decodedKey.startsWith(queryKey);
|
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
||||||
|
let shouldBeHandledAsArray = decodedKey.includes("[") && (dotNotatedKey === queryKey || dotNotatedKey.startsWith(`${queryKey}.`));
|
||||||
if (!shouldBeHandledAsArray) {
|
if (!shouldBeHandledAsArray) {
|
||||||
data[key] = value;
|
data[key] = value;
|
||||||
} else {
|
} else {
|
||||||
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
|
||||||
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
|
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+48
-14
@@ -5164,6 +5164,22 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
function isNumeric(subject) {
|
function isNumeric(subject) {
|
||||||
return !isNaN(parseInt(subject));
|
return !isNaN(parseInt(subject));
|
||||||
}
|
}
|
||||||
|
function dataDelete(object, key) {
|
||||||
|
let segments = parsePathSegments(key);
|
||||||
|
if (segments.length === 1) {
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
object.splice(segments[0], 1);
|
||||||
|
} else {
|
||||||
|
delete object[segments[0]];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let firstSegment = segments.shift();
|
||||||
|
let restOfSegments = segments.join(".");
|
||||||
|
if (object[firstSegment] !== void 0) {
|
||||||
|
dataDelete(object[firstSegment], restOfSegments);
|
||||||
|
}
|
||||||
|
}
|
||||||
function diff(left, right, diffs = {}, path = "") {
|
function diff(left, right, diffs = {}, path = "") {
|
||||||
if (left === right)
|
if (left === right)
|
||||||
return diffs;
|
return diffs;
|
||||||
@@ -13853,17 +13869,23 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
if (isProgrammaticClick(e)) {
|
if (isProgrammaticClick(e)) {
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
el.addEventListener("mousedown", (e) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => {
|
callback((whenReleased) => {
|
||||||
let handler = (e2) => {
|
let handler = (e2) => {
|
||||||
@@ -13879,6 +13901,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
el.addEventListener("keydown", (e) => {
|
el.addEventListener("keydown", (e) => {
|
||||||
if (isNotPlainEnterKey(e))
|
if (isNotPlainEnterKey(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
});
|
});
|
||||||
@@ -13901,6 +13925,20 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
function createUrlObjectFromString2(urlString) {
|
function createUrlObjectFromString2(urlString) {
|
||||||
return urlString !== null && new URL(urlString, document.baseURI);
|
return urlString !== null && new URL(urlString, document.baseURI);
|
||||||
}
|
}
|
||||||
|
function linkShouldBeHandledNatively(linkEl, destination = extractDestinationFromLink(linkEl)) {
|
||||||
|
if (!destination)
|
||||||
|
return true;
|
||||||
|
if (!["http:", "https:"].includes(destination.protocol))
|
||||||
|
return true;
|
||||||
|
if (destination.origin !== window.location.origin)
|
||||||
|
return true;
|
||||||
|
if (linkEl.hasAttribute("download"))
|
||||||
|
return true;
|
||||||
|
let target = linkEl.getAttribute("target")?.trim().toLowerCase();
|
||||||
|
if (target && target !== "_self")
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function getUriStringFromUrlObject(urlObject) {
|
function getUriStringFromUrlObject(urlObject) {
|
||||||
return urlObject.pathname + urlObject.search + urlObject.hash;
|
return urlObject.pathname + urlObject.search + urlObject.hash;
|
||||||
}
|
}
|
||||||
@@ -14359,7 +14397,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
var enablePersist = true;
|
var enablePersist = true;
|
||||||
var showProgressBar = true;
|
var showProgressBar = true;
|
||||||
var restoreScroll = true;
|
var restoreScroll = true;
|
||||||
var autofocus = false;
|
|
||||||
function navigate_default(Alpine25) {
|
function navigate_default(Alpine25) {
|
||||||
Alpine25.navigate = (url, options = {}) => {
|
Alpine25.navigate = (url, options = {}) => {
|
||||||
let { preserveScroll = false } = options;
|
let { preserveScroll = false } = options;
|
||||||
@@ -14382,7 +14419,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
let preserveScroll = modifiers.includes("preserve-scroll");
|
let preserveScroll = modifiers.includes("preserve-scroll");
|
||||||
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
if (linkShouldBeHandledNatively(el, destination))
|
||||||
return;
|
return;
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
@@ -14392,8 +14429,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
});
|
});
|
||||||
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
|
||||||
return;
|
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
}, () => {
|
}, () => {
|
||||||
@@ -14441,10 +14476,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
afterNewScriptsAreDoneLoading(() => {
|
afterNewScriptsAreDoneLoading(() => {
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
setTimeout(() => {
|
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
});
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine25);
|
nowInitializeAlpineOnTheNewPage(Alpine25);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
showProgressBar && finishAndHideProgressBar();
|
showProgressBar && finishAndHideProgressBar();
|
||||||
});
|
});
|
||||||
@@ -14483,6 +14516,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
||||||
onSwap: (callback) => swapCallbacks.push(callback)
|
onSwap: (callback) => swapCallbacks.push(callback)
|
||||||
});
|
});
|
||||||
|
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
|
||||||
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
||||||
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
|
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
|
||||||
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
||||||
@@ -14499,8 +14533,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
restoreScrollPositionOrScrollToTop();
|
restoreScrollPositionOrScrollToTop();
|
||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine25);
|
nowInitializeAlpineOnTheNewPage(Alpine25);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -14665,24 +14699,24 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data = fromQueryString(search, key);
|
let data = fromQueryString(search, key);
|
||||||
return Object.keys(data).includes(key);
|
return dataGet(data, key) !== void 0;
|
||||||
},
|
},
|
||||||
get(url, key) {
|
get(url, key) {
|
||||||
let search = url.search;
|
let search = url.search;
|
||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data = fromQueryString(search, key);
|
let data = fromQueryString(search, key);
|
||||||
return data[key];
|
return dataGet(data, key);
|
||||||
},
|
},
|
||||||
set(url, key, value) {
|
set(url, key, value) {
|
||||||
let data = fromQueryString(url.search, key);
|
let data = fromQueryString(url.search, key);
|
||||||
data[key] = stripNulls(unwrap(value));
|
dataSet(data, key, stripNulls(unwrap(value)));
|
||||||
url.search = toQueryString(data);
|
url.search = toQueryString(data);
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
remove(url, key) {
|
remove(url, key) {
|
||||||
let data = fromQueryString(url.search, key);
|
let data = fromQueryString(url.search, key);
|
||||||
delete data[key];
|
dataDelete(data, key);
|
||||||
url.search = toQueryString(data);
|
url.search = toQueryString(data);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -14737,11 +14771,11 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
return;
|
return;
|
||||||
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
||||||
let decodedKey = decodeURIComponent(key);
|
let decodedKey = decodeURIComponent(key);
|
||||||
let shouldBeHandledAsArray = decodedKey.includes("[") && decodedKey.startsWith(queryKey);
|
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
||||||
|
let shouldBeHandledAsArray = decodedKey.includes("[") && (dotNotatedKey === queryKey || dotNotatedKey.startsWith(`${queryKey}.`));
|
||||||
if (!shouldBeHandledAsArray) {
|
if (!shouldBeHandledAsArray) {
|
||||||
data[key] = value;
|
data[key] = value;
|
||||||
} else {
|
} else {
|
||||||
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
|
||||||
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
|
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-11
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+48
-14
@@ -9304,6 +9304,22 @@ function dataSet(object, key, value) {
|
|||||||
function isNumeric(subject) {
|
function isNumeric(subject) {
|
||||||
return !isNaN(parseInt(subject));
|
return !isNaN(parseInt(subject));
|
||||||
}
|
}
|
||||||
|
function dataDelete(object, key) {
|
||||||
|
let segments = parsePathSegments(key);
|
||||||
|
if (segments.length === 1) {
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
object.splice(segments[0], 1);
|
||||||
|
} else {
|
||||||
|
delete object[segments[0]];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let firstSegment = segments.shift();
|
||||||
|
let restOfSegments = segments.join(".");
|
||||||
|
if (object[firstSegment] !== void 0) {
|
||||||
|
dataDelete(object[firstSegment], restOfSegments);
|
||||||
|
}
|
||||||
|
}
|
||||||
function diff(left, right, diffs = {}, path = "") {
|
function diff(left, right, diffs = {}, path = "") {
|
||||||
if (left === right)
|
if (left === right)
|
||||||
return diffs;
|
return diffs;
|
||||||
@@ -12647,17 +12663,23 @@ function whenThisLinkIsPressed(el, callback) {
|
|||||||
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
if (isProgrammaticClick(e)) {
|
if (isProgrammaticClick(e)) {
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
el.addEventListener("mousedown", (e) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => {
|
callback((whenReleased) => {
|
||||||
let handler = (e2) => {
|
let handler = (e2) => {
|
||||||
@@ -12673,6 +12695,8 @@ function whenThisLinkIsPressed(el, callback) {
|
|||||||
el.addEventListener("keydown", (e) => {
|
el.addEventListener("keydown", (e) => {
|
||||||
if (isNotPlainEnterKey(e))
|
if (isNotPlainEnterKey(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
});
|
});
|
||||||
@@ -12695,6 +12719,20 @@ function extractDestinationFromLink(linkEl) {
|
|||||||
function createUrlObjectFromString2(urlString) {
|
function createUrlObjectFromString2(urlString) {
|
||||||
return urlString !== null && new URL(urlString, document.baseURI);
|
return urlString !== null && new URL(urlString, document.baseURI);
|
||||||
}
|
}
|
||||||
|
function linkShouldBeHandledNatively(linkEl, destination = extractDestinationFromLink(linkEl)) {
|
||||||
|
if (!destination)
|
||||||
|
return true;
|
||||||
|
if (!["http:", "https:"].includes(destination.protocol))
|
||||||
|
return true;
|
||||||
|
if (destination.origin !== window.location.origin)
|
||||||
|
return true;
|
||||||
|
if (linkEl.hasAttribute("download"))
|
||||||
|
return true;
|
||||||
|
let target = linkEl.getAttribute("target")?.trim().toLowerCase();
|
||||||
|
if (target && target !== "_self")
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function getUriStringFromUrlObject(urlObject) {
|
function getUriStringFromUrlObject(urlObject) {
|
||||||
return urlObject.pathname + urlObject.search + urlObject.hash;
|
return urlObject.pathname + urlObject.search + urlObject.hash;
|
||||||
}
|
}
|
||||||
@@ -13153,7 +13191,6 @@ function ignoreAttributes(subject, attributesToRemove) {
|
|||||||
var enablePersist = true;
|
var enablePersist = true;
|
||||||
var showProgressBar = true;
|
var showProgressBar = true;
|
||||||
var restoreScroll = true;
|
var restoreScroll = true;
|
||||||
var autofocus = false;
|
|
||||||
function navigate_default(Alpine25) {
|
function navigate_default(Alpine25) {
|
||||||
Alpine25.navigate = (url, options = {}) => {
|
Alpine25.navigate = (url, options = {}) => {
|
||||||
let { preserveScroll = false } = options;
|
let { preserveScroll = false } = options;
|
||||||
@@ -13176,7 +13213,7 @@ function navigate_default(Alpine25) {
|
|||||||
let preserveScroll = modifiers.includes("preserve-scroll");
|
let preserveScroll = modifiers.includes("preserve-scroll");
|
||||||
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
if (linkShouldBeHandledNatively(el, destination))
|
||||||
return;
|
return;
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
@@ -13186,8 +13223,6 @@ function navigate_default(Alpine25) {
|
|||||||
});
|
});
|
||||||
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
|
||||||
return;
|
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
}, () => {
|
}, () => {
|
||||||
@@ -13235,10 +13270,8 @@ function navigate_default(Alpine25) {
|
|||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
afterNewScriptsAreDoneLoading(() => {
|
afterNewScriptsAreDoneLoading(() => {
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
setTimeout(() => {
|
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
});
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine25);
|
nowInitializeAlpineOnTheNewPage(Alpine25);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
showProgressBar && finishAndHideProgressBar();
|
showProgressBar && finishAndHideProgressBar();
|
||||||
});
|
});
|
||||||
@@ -13277,6 +13310,7 @@ function navigate_default(Alpine25) {
|
|||||||
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
||||||
onSwap: (callback) => swapCallbacks.push(callback)
|
onSwap: (callback) => swapCallbacks.push(callback)
|
||||||
});
|
});
|
||||||
|
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
|
||||||
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
||||||
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
|
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
|
||||||
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
||||||
@@ -13293,8 +13327,8 @@ function navigate_default(Alpine25) {
|
|||||||
restoreScrollPositionOrScrollToTop();
|
restoreScrollPositionOrScrollToTop();
|
||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine25);
|
nowInitializeAlpineOnTheNewPage(Alpine25);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13459,24 +13493,24 @@ function queryStringUtils() {
|
|||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data = fromQueryString(search, key);
|
let data = fromQueryString(search, key);
|
||||||
return Object.keys(data).includes(key);
|
return dataGet(data, key) !== void 0;
|
||||||
},
|
},
|
||||||
get(url, key) {
|
get(url, key) {
|
||||||
let search = url.search;
|
let search = url.search;
|
||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data = fromQueryString(search, key);
|
let data = fromQueryString(search, key);
|
||||||
return data[key];
|
return dataGet(data, key);
|
||||||
},
|
},
|
||||||
set(url, key, value) {
|
set(url, key, value) {
|
||||||
let data = fromQueryString(url.search, key);
|
let data = fromQueryString(url.search, key);
|
||||||
data[key] = stripNulls(unwrap(value));
|
dataSet(data, key, stripNulls(unwrap(value)));
|
||||||
url.search = toQueryString(data);
|
url.search = toQueryString(data);
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
remove(url, key) {
|
remove(url, key) {
|
||||||
let data = fromQueryString(url.search, key);
|
let data = fromQueryString(url.search, key);
|
||||||
delete data[key];
|
dataDelete(data, key);
|
||||||
url.search = toQueryString(data);
|
url.search = toQueryString(data);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -13531,11 +13565,11 @@ function fromQueryString(search, queryKey) {
|
|||||||
return;
|
return;
|
||||||
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
||||||
let decodedKey = decodeURIComponent(key);
|
let decodedKey = decodeURIComponent(key);
|
||||||
let shouldBeHandledAsArray = decodedKey.includes("[") && decodedKey.startsWith(queryKey);
|
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
||||||
|
let shouldBeHandledAsArray = decodedKey.includes("[") && (dotNotatedKey === queryKey || dotNotatedKey.startsWith(`${queryKey}.`));
|
||||||
if (!shouldBeHandledAsArray) {
|
if (!shouldBeHandledAsArray) {
|
||||||
data[key] = value;
|
data[key] = value;
|
||||||
} else {
|
} else {
|
||||||
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
|
||||||
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
|
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+48
-14
@@ -405,6 +405,22 @@
|
|||||||
function isNumeric(subject) {
|
function isNumeric(subject) {
|
||||||
return !isNaN(parseInt(subject));
|
return !isNaN(parseInt(subject));
|
||||||
}
|
}
|
||||||
|
function dataDelete(object, key) {
|
||||||
|
let segments = parsePathSegments(key);
|
||||||
|
if (segments.length === 1) {
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
object.splice(segments[0], 1);
|
||||||
|
} else {
|
||||||
|
delete object[segments[0]];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let firstSegment = segments.shift();
|
||||||
|
let restOfSegments = segments.join(".");
|
||||||
|
if (object[firstSegment] !== void 0) {
|
||||||
|
dataDelete(object[firstSegment], restOfSegments);
|
||||||
|
}
|
||||||
|
}
|
||||||
function diff(left, right, diffs = {}, path = "") {
|
function diff(left, right, diffs = {}, path = "") {
|
||||||
if (left === right)
|
if (left === right)
|
||||||
return diffs;
|
return diffs;
|
||||||
@@ -12440,17 +12456,23 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
if (isProgrammaticClick(e)) {
|
if (isProgrammaticClick(e)) {
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
el.addEventListener("mousedown", (e) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if (isNotPlainLeftClick(e))
|
if (isNotPlainLeftClick(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => {
|
callback((whenReleased) => {
|
||||||
let handler4 = (e2) => {
|
let handler4 = (e2) => {
|
||||||
@@ -12466,6 +12488,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
el.addEventListener("keydown", (e) => {
|
el.addEventListener("keydown", (e) => {
|
||||||
if (isNotPlainEnterKey(e))
|
if (isNotPlainEnterKey(e))
|
||||||
return;
|
return;
|
||||||
|
if (linkShouldBeHandledNatively(el))
|
||||||
|
return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callback((whenReleased) => whenReleased());
|
callback((whenReleased) => whenReleased());
|
||||||
});
|
});
|
||||||
@@ -12488,6 +12512,20 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
function createUrlObjectFromString2(urlString) {
|
function createUrlObjectFromString2(urlString) {
|
||||||
return urlString !== null && new URL(urlString, document.baseURI);
|
return urlString !== null && new URL(urlString, document.baseURI);
|
||||||
}
|
}
|
||||||
|
function linkShouldBeHandledNatively(linkEl, destination = extractDestinationFromLink(linkEl)) {
|
||||||
|
if (!destination)
|
||||||
|
return true;
|
||||||
|
if (!["http:", "https:"].includes(destination.protocol))
|
||||||
|
return true;
|
||||||
|
if (destination.origin !== window.location.origin)
|
||||||
|
return true;
|
||||||
|
if (linkEl.hasAttribute("download"))
|
||||||
|
return true;
|
||||||
|
let target = linkEl.getAttribute("target")?.trim().toLowerCase();
|
||||||
|
if (target && target !== "_self")
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function getUriStringFromUrlObject(urlObject) {
|
function getUriStringFromUrlObject(urlObject) {
|
||||||
return urlObject.pathname + urlObject.search + urlObject.hash;
|
return urlObject.pathname + urlObject.search + urlObject.hash;
|
||||||
}
|
}
|
||||||
@@ -12944,7 +12982,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
var enablePersist = true;
|
var enablePersist = true;
|
||||||
var showProgressBar = true;
|
var showProgressBar = true;
|
||||||
var restoreScroll = true;
|
var restoreScroll = true;
|
||||||
var autofocus = false;
|
|
||||||
function navigate_default(Alpine3) {
|
function navigate_default(Alpine3) {
|
||||||
Alpine3.navigate = (url, options = {}) => {
|
Alpine3.navigate = (url, options = {}) => {
|
||||||
let { preserveScroll = false } = options;
|
let { preserveScroll = false } = options;
|
||||||
@@ -12967,7 +13004,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
let preserveScroll = modifiers.includes("preserve-scroll");
|
let preserveScroll = modifiers.includes("preserve-scroll");
|
||||||
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
if (linkShouldBeHandledNatively(el, destination))
|
||||||
return;
|
return;
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
@@ -12977,8 +13014,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
});
|
});
|
||||||
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
whenThisLinkIsPressed(el, (whenItIsReleased) => {
|
||||||
let destination = extractDestinationFromLink(el);
|
let destination = extractDestinationFromLink(el);
|
||||||
if (!destination)
|
|
||||||
return;
|
|
||||||
prefetchHtml(destination, (html, finalDestination) => {
|
prefetchHtml(destination, (html, finalDestination) => {
|
||||||
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
|
||||||
}, () => {
|
}, () => {
|
||||||
@@ -13026,10 +13061,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
afterNewScriptsAreDoneLoading(() => {
|
afterNewScriptsAreDoneLoading(() => {
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
setTimeout(() => {
|
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
});
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine3);
|
nowInitializeAlpineOnTheNewPage(Alpine3);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
showProgressBar && finishAndHideProgressBar();
|
showProgressBar && finishAndHideProgressBar();
|
||||||
});
|
});
|
||||||
@@ -13068,6 +13101,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
|
||||||
onSwap: (callback) => swapCallbacks.push(callback)
|
onSwap: (callback) => swapCallbacks.push(callback)
|
||||||
});
|
});
|
||||||
|
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
|
||||||
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
|
||||||
preventAlpineFromPickingUpDomChanges(Alpine3, (andAfterAllThis) => {
|
preventAlpineFromPickingUpDomChanges(Alpine3, (andAfterAllThis) => {
|
||||||
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
enablePersist && storePersistantElementsForLater((persistedEl) => {
|
||||||
@@ -13084,8 +13118,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
restoreScrollPositionOrScrollToTop();
|
restoreScrollPositionOrScrollToTop();
|
||||||
swapCallbacks.forEach((callback) => callback());
|
swapCallbacks.forEach((callback) => callback());
|
||||||
andAfterAllThis(() => {
|
andAfterAllThis(() => {
|
||||||
autofocus && autofocusElementsWithTheAutofocusAttribute();
|
|
||||||
nowInitializeAlpineOnTheNewPage(Alpine3);
|
nowInitializeAlpineOnTheNewPage(Alpine3);
|
||||||
|
autofocusElementsWithTheAutofocusAttribute();
|
||||||
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
fireEventForOtherLibrariesToHookInto("alpine:navigated");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13250,24 +13284,24 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data2 = fromQueryString(search, key);
|
let data2 = fromQueryString(search, key);
|
||||||
return Object.keys(data2).includes(key);
|
return dataGet(data2, key) !== void 0;
|
||||||
},
|
},
|
||||||
get(url, key) {
|
get(url, key) {
|
||||||
let search = url.search;
|
let search = url.search;
|
||||||
if (!search)
|
if (!search)
|
||||||
return false;
|
return false;
|
||||||
let data2 = fromQueryString(search, key);
|
let data2 = fromQueryString(search, key);
|
||||||
return data2[key];
|
return dataGet(data2, key);
|
||||||
},
|
},
|
||||||
set(url, key, value) {
|
set(url, key, value) {
|
||||||
let data2 = fromQueryString(url.search, key);
|
let data2 = fromQueryString(url.search, key);
|
||||||
data2[key] = stripNulls(unwrap(value));
|
dataSet(data2, key, stripNulls(unwrap(value)));
|
||||||
url.search = toQueryString(data2);
|
url.search = toQueryString(data2);
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
remove(url, key) {
|
remove(url, key) {
|
||||||
let data2 = fromQueryString(url.search, key);
|
let data2 = fromQueryString(url.search, key);
|
||||||
delete data2[key];
|
dataDelete(data2, key);
|
||||||
url.search = toQueryString(data2);
|
url.search = toQueryString(data2);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -13322,11 +13356,11 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
|
|||||||
return;
|
return;
|
||||||
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
value = decodeURIComponent(value.replaceAll("+", "%20"));
|
||||||
let decodedKey = decodeURIComponent(key);
|
let decodedKey = decodeURIComponent(key);
|
||||||
let shouldBeHandledAsArray = decodedKey.includes("[") && decodedKey.startsWith(queryKey);
|
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
||||||
|
let shouldBeHandledAsArray = decodedKey.includes("[") && (dotNotatedKey === queryKey || dotNotatedKey.startsWith(`${queryKey}.`));
|
||||||
if (!shouldBeHandledAsArray) {
|
if (!shouldBeHandledAsArray) {
|
||||||
data2[key] = value;
|
data2[key] = value;
|
||||||
} else {
|
} else {
|
||||||
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
|
|
||||||
insertDotNotatedValueIntoData(dotNotatedKey, value, data2);
|
insertDotNotatedValueIntoData(dotNotatedKey, value, data2);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+9
-9
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user