Merge branch 'issue/Company_Dusk_Failure'

# Conflicts:
#	Modules/Helpdesk/changelog.yaml
This commit is contained in:
Mark Gregory
2026-07-23 14:02:32 +01:00
116 changed files with 4618 additions and 4799 deletions
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
});
}
};
@@ -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
namespace Modules\Platform\app\Models;
namespace Modules\Company\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes;
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;
class Company extends Model
{
use HasFactory, SoftDeletes;
use SoftDeletes;
protected $fillable = [
'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 = [
@@ -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
{
return $this->hasMany(User::class);
+54
View File
@@ -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>
+27
View File
@@ -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');
});
+8
View File
@@ -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.
+14
View File
@@ -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
{
/**
* Display ISO 3166 country records.
*/
public function index(Request $request): View
public function __invoke(Request $request): View
{
$search = trim((string) $request->query('search', ''));
$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 === 'inactive', fn ($query) => $query->where('is_active', false))
->when($region !== 'all' && $region !== '', fn ($query) => $query->where('region', $region))
->ordered()
->orderByRaw('LOWER(COALESCE(display_name, common_name, name)) ASC')
->paginate(50)
->withQueryString();
$activeCount = Country::query()->where('is_active', true)->count();
$inactiveCount = Country::query()->where('is_active', false)->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
{
return view('countries::create', ['country' => new Country]);
}
/**
* Store a new ISO country record.
*/
public function store(StoreCountryRequest $request): RedirectResponse
{
$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
{
$country->load(['subdivisions' => fn ($query) => $query->ordered()->limit(250)]);
$usageRows = $usageReport->forCountry($country);
return view('countries::show', compact('country', 'usageRows'));
}
/**
* Show the edit form.
*/
public function edit(Country $country): View
{
return view('countries::edit', compact('country'));
}
/**
* Update all editable ISO/support fields.
*/
public function update(UpdateCountryRequest $request, Country $country): RedirectResponse
{
$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
{
$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
{
$data = $request->validated();
@@ -130,7 +125,10 @@ class CountriesController extends Controller
foreach (['languages', 'timezones', 'metadata'] as $jsonField) {
$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) {
+1 -1
View File
@@ -6,7 +6,7 @@ use Modules\Countries\Http\Controllers\CountrySyncController;
use Modules\Countries\Http\Controllers\ReferenceDataController;
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::post('/', [CountriesController::class, 'store'])->name('store');
Route::post('/sync', CountrySyncController::class)->name('sync');
@@ -2,9 +2,9 @@
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 Illuminate\Database\Eloquent\Factories\Factory;
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
{
$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();
$openCount = (clone $base)->whereNull('completed_at')->count();
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
@@ -31,7 +42,11 @@ final class HelpdeskDashboardWidget extends Component
'closedPercent' => $closedPercent,
'openPercent' => $openPercent,
'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(),
]);
}
}
+22 -1
View File
@@ -25,6 +25,18 @@ final class KanbanBoard extends Component
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()
->where('is_default', true)
->orderBy('sort_order')
@@ -139,6 +151,15 @@ final class KanbanBoard extends Component
$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.
*/
@@ -185,7 +206,7 @@ final class KanbanBoard extends Component
}
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())
->where('board_id', $board->id)
->latest('last_activity_at')
@@ -115,7 +115,9 @@ final class TicketCreate extends Component
'due_at' => $this->due_at,
'tag_ids' => $this->tag_ids,
], $this->attachments);
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
return redirect()->to(route('helpdesk.tickets.index'));
} finally {
$this->isSaving = false;
+3 -2
View File
@@ -248,8 +248,9 @@ final class TicketEdit extends Component
private function freshTicket(Ticket $ticket): Ticket
{
return $ticket->fresh([
'owner',
'agent',
'user:id,name,email',
'owner:id,name,email',
'agent:id,name,email',
'status',
'priority',
'category',
+9 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -124,7 +125,14 @@ final class TicketIndex extends Component
$search = trim($this->search);
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')
->visibleTo($user)
->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')
->paginate(15);
}
}
+1 -3
View File
@@ -4,17 +4,15 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Company\Models\Company;
final class Category extends Model
{
use HasFactory;
protected $table = 'helpdesk_categories';
+1 -3
View File
@@ -4,8 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\Company;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Modules\Company\Models\Company;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -13,7 +12,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Project extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'helpdesk_projects';
+1 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
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\Relations\BelongsTo;
+8 -3
View File
@@ -8,11 +8,11 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Modules\Helpdesk\Database\Factories\TicketFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Helpdesk\Database\Factories\TicketFactory;
/**
* @property int $id
@@ -23,11 +23,12 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Ticket extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'helpdesk_tickets';
protected $with = ['user'];
/** @var list<string> */
protected $fillable = [
'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');
}
public function tickets(): Ticket|HasMany
{
return $this->hasMany(Ticket::class, 'ticket_id');
}
/** @return BelongsTo<User, Ticket> */
public function owner(): BelongsTo
{
@@ -209,5 +215,4 @@ final class Ticket extends Model
{
return TicketFactory::new();
}
}
@@ -10,7 +10,7 @@
class="space-y-2"
x-data="{
model: @js($model),
value: @entangle($model).defer,
value: @entangle($model),
init() {
this.$nextTick(() => {
this.writeEditorValue(this.value || '');
@@ -21,33 +21,41 @@
return;
}
this.$refs.editor.textContent = value || '';
},
syncToLivewire(live = false) {
const value = this.$refs.editor?.innerText || '';
const nextValue = value || '';
const currentValue = 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);
},
resetEditor() {
this.value = '';
this.writeEditorValue('');
this.syncToLivewire(false);
this.syncToLivewire(true);
},
runCommand(command) {
this.$refs.editor?.focus();
document.execCommand(command, false, null);
this.syncToLivewire(false);
this.syncToLivewire(true);
},
wrapBlock(tagName) {
this.$refs.editor?.focus();
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()"
>
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
@@ -108,7 +116,8 @@
data-helpdesk-sync-target="{{ $model }}"
data-helpdesk-editor-model="{{ $model }}"
@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"
></div>
@@ -129,6 +138,6 @@
</div>
<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>
</div>
@@ -1,5 +1,9 @@
@props(['title' => null])
<x-layouts.app :title="$title ?? __('Helpdesk')">
{{ $slot }}
</x-layouts.app>
{{--
Helpdesk compatibility component.
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>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
</div>
{{-- Recent Tickets --}}
<div class="divide-y divide-white/10">
@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">
<div class="flex items-center gap-3 text-sm font-semibold text-slate-300">
<span class="h-2 w-2 rounded-full bg-blue-400"></span>
<span>{{ $ticket->reference }}</span>
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="flex flex-nowrap items-center gap-4 px-5 py-4 transition hover:bg-blue-500/10">
<div class="flex w-36 shrink-0 items-center gap-3 text-sm font-semibold text-slate-300">
<span class="h-2 w-2 shrink-0 rounded-full bg-blue-400"></span>
<span class="whitespace-nowrap">{{ $ticket->reference }}</span>
</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="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>
<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>
</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>
@empty
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
@endforelse
</div>
{{-- Recent Tickets End--}}
</div>
<div class="space-y-5">
@@ -130,22 +145,52 @@
</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">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-black text-white">Recent commits</h2>
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">Git setup</a>
<details
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
dusk="recent-commits-card">
<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>
<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">
@forelse ($recentCommits as $commit)
<div class="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div class="font-mono text-xs text-slate-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
<div class="mt-1 truncate text-sm font-semibold text-slate-100">{{ $commit->message }}</div>
<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-zinc-500 dark:text-zinc-400">
{{ $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>
@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
</div>
</div>
</details>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
@vite(['resources/css/app.css'])
<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
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">
@@ -46,7 +46,7 @@
<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">
<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">
{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}
</p>
@@ -57,9 +57,13 @@
</div>
<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)
<option value="{{ $availableBoard->id }}">
<option value="{{ $availableBoard->id }}" @selected((int) $board_id === (int) $availableBoard->id)>
{{ $availableBoard->name }}{{ $availableBoard->owner ? ' · ' . $availableBoard->owner->name : '' }}
</option>
@endforeach
@@ -149,12 +153,22 @@
</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
class="mb-1 block text-[11px] font-bold uppercase tracking-wide text-emerald-200/80">Board</label>
<div class="hd-ticket-board-select-wrap">
<select class="hd-ticket-board-select"
wire:change="moveTicketToBoard({{ $ticket->id }}, $event.target.value)"
<select name="board_id"
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">
@foreach ($boards as $ticketBoard)
<option value="{{ $ticketBoard->id }}"
@@ -162,7 +176,7 @@
@endforeach
</select>
</div>
</div>
</form>
@if ($ticket->tags->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-1">
@foreach ($ticket->tags as $tag)
@@ -5,8 +5,7 @@
->unique()
->implode(',');
@endphp
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create">
@vite(['resources/css/app.css', 'resources/js/app.js'])
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create" x-data="{ helpdeskAttachmentsUploading: false }">
@include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create ticket',
@@ -16,39 +15,38 @@
<form
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="
if (helpdeskAttachmentsUploading) {
$event.preventDefault();
return;
}
if (helpdeskAttachmentsUploading) {
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
const target = Array.from(document.querySelectorAll('textarea')).find(function (textarea) {
$event.preventDefault();
return textarea.getAttribute('wire:model.defer') === 'body'
return;
|| textarea.getAttribute('wire:model') === 'body'
}
|| textarea.getAttribute('name') === 'body'
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
|| textarea.id === 'body';
const target = document.querySelector('textarea[wire\\:model\\.defer=&quot;body&quot;], textarea[name=&quot;body&quot;], #body');
});
const body = (editor?.innerText || editor?.textContent || editor?.value || '').trim();
if (editor && target) {
if (target) {
target.value = body;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
target.value = editor.innerHTML?.trim?.() || editor.value?.trim?.() || '';
if ($wire) {
$wire.set('body', body, false);
}
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
$dispatch('helpdesk-editor-sync', { model: 'body' });
"
enctype="multipart/form-data"
$dispatch('helpdesk-editor-sync', { model: 'body', live: false });
"
class="helpdesk-ticket-form space-y-6"
>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
@@ -82,7 +80,7 @@
</label>
<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"
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>
@@ -105,7 +103,7 @@
</label>
<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"
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">
@@ -128,7 +126,7 @@
</label>
<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"
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">
@@ -151,7 +149,7 @@
</label>
<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"
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>
@@ -173,7 +171,7 @@
</label>
<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"
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>
@@ -195,7 +193,7 @@
</label>
<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"
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>
@@ -278,16 +276,16 @@
</div>
<div class="lg:col-span-3 xl:col-span-6">
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="helpdeskAttachmentDropzone('attachments')"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="dropFiles($event)"
x-on:livewire-upload-start="uploading = true; progress = 0"
<div
wire:key="ticket-create-attachments-uploader"
class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="{ progress: 0 }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-error="uploading = false"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
@@ -295,7 +293,7 @@
</label>
<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>
</div>
@@ -307,29 +305,55 @@
</div>
<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"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
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">
<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>
</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"
x-ref="input" x-on:change="refreshSelectedFiles()">
<template x-if="selectedFiles.length > 0">
<div class="mt-4 grid gap-2 sm:grid-cols-2">
<template x-for="file in selectedFiles" :key="file.name + file.size">
<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
type="button"
x-on:click="$refs.attachmentsInput.click()"
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">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
Add attachments
</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>
<span class="min-w-0 flex-1 truncate" x-text="file.name"></span>
<span class="shrink-0 text-zinc-500" x-text="formatSize(file.size)"></span>
<span class="min-w-0 flex-1 truncate">{{ $attachment->getClientOriginalName() }}</span>
<span class="shrink-0 text-zinc-500">{{ number_format($attachment->getSize() / 1024, 1) }} KB</span>
</div>
</template>
@endforeach
</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">
<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>
@@ -378,71 +402,4 @@
</div>
</div>
</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>
@@ -83,22 +83,29 @@
</td>
<td class="px-4 py-3">
<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)
· {{ $ticket->project->title }}
@endif @if ($ticket->team)
· {{ $ticket->team->name }}
@endif
@endif
@if ($ticket->team)
· {{ $ticket->team->name }}
@endif
</div>
</td>
<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 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 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">
@if (($ticket->attachments_count ?? 0) > 0)
<span
@@ -650,6 +650,13 @@
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]');
if (!replyButton) {
@@ -708,6 +715,12 @@
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]');
if (fileInput) {
@@ -730,7 +743,16 @@
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 output = container.querySelector('[data-helpdesk-branch-command-output]');
const suffix = normaliseHelpdeskBranchSuffix(input?.value || 'short-description');
@@ -743,6 +765,13 @@
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) {
const slug = String(value || '')
.toLowerCase()
@@ -754,6 +783,22 @@
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) {
const container = button.closest('[data-helpdesk-branch-command]');
const status = container?.querySelector('[data-helpdesk-branch-copy-status]');
@@ -763,19 +808,7 @@
return;
}
try {
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();
}
await copyTextToClipboard(command);
if (status) {
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);
function updateCommentToggleButtons() {
@@ -1286,75 +1337,216 @@
</div>
</div>
@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
class="rounded-xl border border-zinc-200 bg-white p-4 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">
<div class="flex 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">Git activity</h2>
<p class="text-xs font-semibold text-violet-100">Branches and commits linked to this ticket</p>
class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
data-helpdesk-developer-actions>
<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
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">Developer actions</h2>
<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>
@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"
data-helpdesk-branch-command
data-helpdesk-branch-prefix="git checkout -b bugfix/{{ $ticket->reference }}-">
<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">
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
</span>
<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">
Branch command
</label>
<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 }}-.
</p>
</div>
</div>
<div class="mt-3 grid gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<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">
<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">
bugfix/{{ $ticket->reference }}-
</span>
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
value="helpdesk-ticket-branch-linking"
class="min-h-10 flex-1 border-0 bg-transparent px-3 py-2 font-mono text-xs font-semibold text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100"
placeholder="short-description"
data-helpdesk-branch-suffix>
@can('update', $ticket)
<div class="border-b border-violet-100 bg-violet-50/70 p-4 dark:border-violet-900/70 dark:bg-violet-950/20">
<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 }}">
<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">
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
</span>
<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">
Branch create command
</label>
<p class="mt-1 text-xs font-semibold text-violet-800/80 dark:text-violet-200/80">
Choose the GitFlow prefix, keep {{ $ticket->reference }}- fixed, add a short lowercase description, then copy the command.
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="grid gap-2 lg:grid-cols-[12rem_minmax(0,1fr)]">
<label for="helpdesk-branch-command-prefix" class="sr-only">GitFlow prefix</label>
<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 }}-
</span>
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
value="helpdesk-ticket-branch-linking"
class="min-h-10 flex-1 border-0 bg-transparent px-3 py-2 font-mono text-xs font-semibold text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100"
placeholder="short-description"
data-helpdesk-branch-suffix>
</div>
</div>
<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"
data-helpdesk-copy-branch-command>
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
Copy command
</button>
<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"
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>
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
Copy branch
</button>
</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>
Copied to clipboard.
</p>
</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>
@endcan
<div class="space-y-2">
@forelse ($ticket->gitCommits as $commit)
<a href="{{ $commit->url ?: '#' }}" target="_blank"
class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800">
<div class="font-mono text-[0.68rem] text-zinc-500">
{{ $commit->short_hash === 'branch' ? 'branch' : $commit->short_hash }} ·
{{ $commit->repository?->name }}
<div class="grid gap-3 border-b border-zinc-100 p-4 dark:border-zinc-800 sm:grid-cols-3">
<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">Default branch</p>
<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">
{{ $isBranchActivity ? 'branch' : $commit->short_hash }} ·
{{ $commit->repository?->name }}
</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)
<div class="mt-1 break-words font-mono text-[0.68rem] font-semibold text-violet-700 dark:text-violet-300">
@@ -1,24 +1,24 @@
<div class="mb-8 overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-sm">
<div class="px-6 py-8 text-white">
<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>
<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' }}
</p>
<h1 class="mt-3 text-3xl font-bold tracking-tight">
<h1 class="mt-2 text-3xl font-semibold tracking-tight">
{{ $title ?? 'Helpdesk' }}
</h1>
@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 }}
</p>
@endif
</div>
@isset($actions)
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap gap-2">
{{ $actions }}
</div>
@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
+2
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketBoardController;
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController;
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}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.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}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store');
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');
}
/** @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 */
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('git checkout -b bugfix/{{ $ticket->reference }}-', $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('copyHelpdeskBranchCommand', $view);
self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view);
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use App\Models\Company;
use Modules\Company\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
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);
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -23,5 +23,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.10.30"
"version": "1.10.42"
}
@@ -3,7 +3,7 @@
namespace Modules\InternalApi\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
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 Illuminate\Http\Request;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
class LicenseKeyController extends Controller
@@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\Payment;
class PaymentController extends Controller
@@ -5,7 +5,7 @@ namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
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\Payment;
use Modules\Platform\app\Models\PlatformPrice;
@@ -4,7 +4,7 @@ namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\Payment;
class StripeWebhookController extends Controller
@@ -5,6 +5,7 @@ namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Modules\Company\Models\Company;
class LicenseKey extends Model
{
+1
View File
@@ -4,6 +4,7 @@ namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Company\Models\Company;
class Payment extends Model
{
@@ -6,7 +6,7 @@ use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
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\WarehouseValidationRule;
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>
+2 -3
View File
@@ -1,12 +1,12 @@
<?php
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\PaymentController;
use Modules\Platform\app\Http\Controllers\PlatformDashboardController;
use Modules\Platform\app\Http\Controllers\PlatformPriceController;
use Modules\Platform\app\Http\Controllers\StripeWebhookController;
use Modules\Company\Http\Controllers\CompanyController;
Route::middleware(['auth', 'verified', 'tenant.assigned'])
->prefix('platform')
@@ -20,13 +20,12 @@ Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin'])
->name('platform.')
->group(function (): void {
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::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store');
Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index');
Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store');
Route::resource('/prices', PlatformPriceController::class)->except(['show']);
Route::resource('/companies', CompanyController::class);
});
Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook');
@@ -3,7 +3,7 @@
namespace Modules\Platform\tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Tests\TestCase;
@@ -4,7 +4,7 @@ namespace Modules\Platform\tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Tests\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>
-35
View File
@@ -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');
+16 -2
View File
@@ -5,13 +5,16 @@ namespace Modules\Warehouse\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
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
{
protected $fillable = [
'company_id', 'uuid',
'company_id', 'division_id', 'uuid',
'code',
'name',
'status',
@@ -38,6 +41,17 @@ class Warehouse extends Model
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
{
return $this->hasMany(WarehouseValidationRule::class);
@@ -4,7 +4,7 @@ namespace Modules\Warehouse\tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Warehouse\app\Models\Warehouse;
use Tests\TestCase;
+1 -1
View File
@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
class CreateNewUser implements CreatesNewUsers
{
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Models;
class Company extends \Modules\Platform\app\Models\Company {}
+18
View File
@@ -5,12 +5,17 @@ namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Modules\Company\Models\Company;
use Modules\Company\Models\CompanyDirectorAssignment;
use Modules\Company\Models\CompanyDivision;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements PasskeyUser
@@ -39,6 +44,19 @@ class User extends Authenticatable implements PasskeyUser
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
{
return Str::of($this->name)
+9
View File
@@ -3,6 +3,8 @@
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\LazyLoadingViolationException;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
@@ -24,6 +26,13 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureDefaults();
Model::preventLazyLoading(! app()->isProduction());
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
throw new LazyLoadingViolationException(
$model,
$relation,
);
});
}
/**
+1
View File
@@ -103,6 +103,7 @@
],
"reload": [
"npm run build:all",
"@php artisan livewire:publish --assets",
"@php artisan optimize:clear",
"@php artisan route:clear",
"@php artisan config:clear",
+1 -1
View File
@@ -218,5 +218,5 @@ return [
],
],
'activator' => 'file',
'activator' => 'database',
];
+73
View File
@@ -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,
],
];
+1 -1
View File
@@ -6,7 +6,7 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Platform\app\Models\Company;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
/**
+2
View File
@@ -24,5 +24,7 @@
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="SESSION_SECURE_COOKIE" value="false"/>
<env name="DUSK_USE_EXTERNAL_SERVER" value="false"/>
</php>
</phpunit>
+48 -14
View File
@@ -10074,6 +10074,22 @@ function dataSet(object, key, value) {
function isNumeric(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 = "") {
if (left === right)
return diffs;
@@ -13417,17 +13433,23 @@ function whenThisLinkIsPressed(el, callback) {
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
el.addEventListener("click", (e) => {
if (isProgrammaticClick(e)) {
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
return;
}
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
});
el.addEventListener("mousedown", (e) => {
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => {
let handler = (e2) => {
@@ -13443,6 +13465,8 @@ function whenThisLinkIsPressed(el, callback) {
el.addEventListener("keydown", (e) => {
if (isNotPlainEnterKey(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
});
@@ -13465,6 +13489,20 @@ function extractDestinationFromLink(linkEl) {
function createUrlObjectFromString2(urlString) {
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) {
return urlObject.pathname + urlObject.search + urlObject.hash;
}
@@ -13923,7 +13961,6 @@ function ignoreAttributes(subject, attributesToRemove) {
var enablePersist = true;
var showProgressBar = true;
var restoreScroll = true;
var autofocus = false;
function navigate_default(Alpine25) {
Alpine25.navigate = (url, options = {}) => {
let { preserveScroll = false } = options;
@@ -13946,7 +13983,7 @@ function navigate_default(Alpine25) {
let preserveScroll = modifiers.includes("preserve-scroll");
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
let destination = extractDestinationFromLink(el);
if (!destination)
if (linkShouldBeHandledNatively(el, destination))
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
@@ -13956,8 +13993,6 @@ function navigate_default(Alpine25) {
});
whenThisLinkIsPressed(el, (whenItIsReleased) => {
let destination = extractDestinationFromLink(el);
if (!destination)
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
}, () => {
@@ -14005,10 +14040,8 @@ function navigate_default(Alpine25) {
swapCallbacks.forEach((callback) => callback());
afterNewScriptsAreDoneLoading(() => {
andAfterAllThis(() => {
setTimeout(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
});
nowInitializeAlpineOnTheNewPage(Alpine25);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
showProgressBar && finishAndHideProgressBar();
});
@@ -14047,6 +14080,7 @@ function navigate_default(Alpine25) {
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
onSwap: (callback) => swapCallbacks.push(callback)
});
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
enablePersist && storePersistantElementsForLater((persistedEl) => {
@@ -14063,8 +14097,8 @@ function navigate_default(Alpine25) {
restoreScrollPositionOrScrollToTop();
swapCallbacks.forEach((callback) => callback());
andAfterAllThis(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
nowInitializeAlpineOnTheNewPage(Alpine25);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
});
});
@@ -14229,24 +14263,24 @@ function queryStringUtils() {
if (!search)
return false;
let data = fromQueryString(search, key);
return Object.keys(data).includes(key);
return dataGet(data, key) !== void 0;
},
get(url, key) {
let search = url.search;
if (!search)
return false;
let data = fromQueryString(search, key);
return data[key];
return dataGet(data, key);
},
set(url, key, value) {
let data = fromQueryString(url.search, key);
data[key] = stripNulls(unwrap(value));
dataSet(data, key, stripNulls(unwrap(value)));
url.search = toQueryString(data);
return url;
},
remove(url, key) {
let data = fromQueryString(url.search, key);
delete data[key];
dataDelete(data, key);
url.search = toQueryString(data);
return url;
}
@@ -14301,11 +14335,11 @@ function fromQueryString(search, queryKey) {
return;
value = decodeURIComponent(value.replaceAll("+", "%20"));
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) {
data[key] = value;
} else {
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
}
});
File diff suppressed because one or more lines are too long
+48 -14
View File
@@ -5164,6 +5164,22 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
function isNumeric(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 = "") {
if (left === right)
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;
el.addEventListener("click", (e) => {
if (isProgrammaticClick(e)) {
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
return;
}
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
});
el.addEventListener("mousedown", (e) => {
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => {
let handler = (e2) => {
@@ -13879,6 +13901,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
el.addEventListener("keydown", (e) => {
if (isNotPlainEnterKey(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
});
@@ -13901,6 +13925,20 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
function createUrlObjectFromString2(urlString) {
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) {
return urlObject.pathname + urlObject.search + urlObject.hash;
}
@@ -14359,7 +14397,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
var enablePersist = true;
var showProgressBar = true;
var restoreScroll = true;
var autofocus = false;
function navigate_default(Alpine25) {
Alpine25.navigate = (url, options = {}) => {
let { preserveScroll = false } = options;
@@ -14382,7 +14419,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
let preserveScroll = modifiers.includes("preserve-scroll");
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
let destination = extractDestinationFromLink(el);
if (!destination)
if (linkShouldBeHandledNatively(el, destination))
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
@@ -14392,8 +14429,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
});
whenThisLinkIsPressed(el, (whenItIsReleased) => {
let destination = extractDestinationFromLink(el);
if (!destination)
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
}, () => {
@@ -14441,10 +14476,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
swapCallbacks.forEach((callback) => callback());
afterNewScriptsAreDoneLoading(() => {
andAfterAllThis(() => {
setTimeout(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
});
nowInitializeAlpineOnTheNewPage(Alpine25);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
showProgressBar && finishAndHideProgressBar();
});
@@ -14483,6 +14516,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
onSwap: (callback) => swapCallbacks.push(callback)
});
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
enablePersist && storePersistantElementsForLater((persistedEl) => {
@@ -14499,8 +14533,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
restoreScrollPositionOrScrollToTop();
swapCallbacks.forEach((callback) => callback());
andAfterAllThis(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
nowInitializeAlpineOnTheNewPage(Alpine25);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
});
});
@@ -14665,24 +14699,24 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
if (!search)
return false;
let data = fromQueryString(search, key);
return Object.keys(data).includes(key);
return dataGet(data, key) !== void 0;
},
get(url, key) {
let search = url.search;
if (!search)
return false;
let data = fromQueryString(search, key);
return data[key];
return dataGet(data, key);
},
set(url, key, value) {
let data = fromQueryString(url.search, key);
data[key] = stripNulls(unwrap(value));
dataSet(data, key, stripNulls(unwrap(value)));
url.search = toQueryString(data);
return url;
},
remove(url, key) {
let data = fromQueryString(url.search, key);
delete data[key];
dataDelete(data, key);
url.search = toQueryString(data);
return url;
}
@@ -14737,11 +14771,11 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
return;
value = decodeURIComponent(value.replaceAll("+", "%20"));
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) {
data[key] = value;
} else {
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
}
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+48 -14
View File
@@ -9304,6 +9304,22 @@ function dataSet(object, key, value) {
function isNumeric(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 = "") {
if (left === right)
return diffs;
@@ -12647,17 +12663,23 @@ function whenThisLinkIsPressed(el, callback) {
let isNotPlainEnterKey = (e) => e.which !== 13 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
el.addEventListener("click", (e) => {
if (isProgrammaticClick(e)) {
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
return;
}
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
});
el.addEventListener("mousedown", (e) => {
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => {
let handler = (e2) => {
@@ -12673,6 +12695,8 @@ function whenThisLinkIsPressed(el, callback) {
el.addEventListener("keydown", (e) => {
if (isNotPlainEnterKey(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
});
@@ -12695,6 +12719,20 @@ function extractDestinationFromLink(linkEl) {
function createUrlObjectFromString2(urlString) {
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) {
return urlObject.pathname + urlObject.search + urlObject.hash;
}
@@ -13153,7 +13191,6 @@ function ignoreAttributes(subject, attributesToRemove) {
var enablePersist = true;
var showProgressBar = true;
var restoreScroll = true;
var autofocus = false;
function navigate_default(Alpine25) {
Alpine25.navigate = (url, options = {}) => {
let { preserveScroll = false } = options;
@@ -13176,7 +13213,7 @@ function navigate_default(Alpine25) {
let preserveScroll = modifiers.includes("preserve-scroll");
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
let destination = extractDestinationFromLink(el);
if (!destination)
if (linkShouldBeHandledNatively(el, destination))
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
@@ -13186,8 +13223,6 @@ function navigate_default(Alpine25) {
});
whenThisLinkIsPressed(el, (whenItIsReleased) => {
let destination = extractDestinationFromLink(el);
if (!destination)
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
}, () => {
@@ -13235,10 +13270,8 @@ function navigate_default(Alpine25) {
swapCallbacks.forEach((callback) => callback());
afterNewScriptsAreDoneLoading(() => {
andAfterAllThis(() => {
setTimeout(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
});
nowInitializeAlpineOnTheNewPage(Alpine25);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
showProgressBar && finishAndHideProgressBar();
});
@@ -13277,6 +13310,7 @@ function navigate_default(Alpine25) {
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
onSwap: (callback) => swapCallbacks.push(callback)
});
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
preventAlpineFromPickingUpDomChanges(Alpine25, (andAfterAllThis) => {
enablePersist && storePersistantElementsForLater((persistedEl) => {
@@ -13293,8 +13327,8 @@ function navigate_default(Alpine25) {
restoreScrollPositionOrScrollToTop();
swapCallbacks.forEach((callback) => callback());
andAfterAllThis(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
nowInitializeAlpineOnTheNewPage(Alpine25);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
});
});
@@ -13459,24 +13493,24 @@ function queryStringUtils() {
if (!search)
return false;
let data = fromQueryString(search, key);
return Object.keys(data).includes(key);
return dataGet(data, key) !== void 0;
},
get(url, key) {
let search = url.search;
if (!search)
return false;
let data = fromQueryString(search, key);
return data[key];
return dataGet(data, key);
},
set(url, key, value) {
let data = fromQueryString(url.search, key);
data[key] = stripNulls(unwrap(value));
dataSet(data, key, stripNulls(unwrap(value)));
url.search = toQueryString(data);
return url;
},
remove(url, key) {
let data = fromQueryString(url.search, key);
delete data[key];
dataDelete(data, key);
url.search = toQueryString(data);
return url;
}
@@ -13531,11 +13565,11 @@ function fromQueryString(search, queryKey) {
return;
value = decodeURIComponent(value.replaceAll("+", "%20"));
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) {
data[key] = value;
} else {
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
}
});
File diff suppressed because one or more lines are too long
+48 -14
View File
@@ -405,6 +405,22 @@
function isNumeric(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 = "") {
if (left === right)
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;
el.addEventListener("click", (e) => {
if (isProgrammaticClick(e)) {
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
return;
}
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
});
el.addEventListener("mousedown", (e) => {
if (isNotPlainLeftClick(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => {
let handler4 = (e2) => {
@@ -12466,6 +12488,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
el.addEventListener("keydown", (e) => {
if (isNotPlainEnterKey(e))
return;
if (linkShouldBeHandledNatively(el))
return;
e.preventDefault();
callback((whenReleased) => whenReleased());
});
@@ -12488,6 +12512,20 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
function createUrlObjectFromString2(urlString) {
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) {
return urlObject.pathname + urlObject.search + urlObject.hash;
}
@@ -12944,7 +12982,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
var enablePersist = true;
var showProgressBar = true;
var restoreScroll = true;
var autofocus = false;
function navigate_default(Alpine3) {
Alpine3.navigate = (url, options = {}) => {
let { preserveScroll = false } = options;
@@ -12967,7 +13004,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
let preserveScroll = modifiers.includes("preserve-scroll");
shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => {
let destination = extractDestinationFromLink(el);
if (!destination)
if (linkShouldBeHandledNatively(el, destination))
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
@@ -12977,8 +13014,6 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
});
whenThisLinkIsPressed(el, (whenItIsReleased) => {
let destination = extractDestinationFromLink(el);
if (!destination)
return;
prefetchHtml(destination, (html, finalDestination) => {
storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination, finalDestination);
}, () => {
@@ -13026,10 +13061,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
swapCallbacks.forEach((callback) => callback());
afterNewScriptsAreDoneLoading(() => {
andAfterAllThis(() => {
setTimeout(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
});
nowInitializeAlpineOnTheNewPage(Alpine3);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
showProgressBar && finishAndHideProgressBar();
});
@@ -13068,6 +13101,7 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
fireEventForOtherLibrariesToHookInto("alpine:navigating", {
onSwap: (callback) => swapCallbacks.push(callback)
});
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement();
updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks(currentPageKey, currentPageUrl);
preventAlpineFromPickingUpDomChanges(Alpine3, (andAfterAllThis) => {
enablePersist && storePersistantElementsForLater((persistedEl) => {
@@ -13084,8 +13118,8 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
restoreScrollPositionOrScrollToTop();
swapCallbacks.forEach((callback) => callback());
andAfterAllThis(() => {
autofocus && autofocusElementsWithTheAutofocusAttribute();
nowInitializeAlpineOnTheNewPage(Alpine3);
autofocusElementsWithTheAutofocusAttribute();
fireEventForOtherLibrariesToHookInto("alpine:navigated");
});
});
@@ -13250,24 +13284,24 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
if (!search)
return false;
let data2 = fromQueryString(search, key);
return Object.keys(data2).includes(key);
return dataGet(data2, key) !== void 0;
},
get(url, key) {
let search = url.search;
if (!search)
return false;
let data2 = fromQueryString(search, key);
return data2[key];
return dataGet(data2, key);
},
set(url, key, value) {
let data2 = fromQueryString(url.search, key);
data2[key] = stripNulls(unwrap(value));
dataSet(data2, key, stripNulls(unwrap(value)));
url.search = toQueryString(data2);
return url;
},
remove(url, key) {
let data2 = fromQueryString(url.search, key);
delete data2[key];
dataDelete(data2, key);
url.search = toQueryString(data2);
return url;
}
@@ -13322,11 +13356,11 @@ ${expression ? 'Expression: "' + expression + '"\n\n' : ""}`, el);
return;
value = decodeURIComponent(value.replaceAll("+", "%20"));
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) {
data2[key] = value;
} else {
let dotNotatedKey = decodedKey.replaceAll("[", ".").replaceAll("]", "");
insertDotNotatedValueIntoData(dotNotatedKey, value, data2);
}
});
File diff suppressed because one or more lines are too long
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