Merge branch 'issue/Company_Dusk_Failure'
# Conflicts: # Modules/Helpdesk/changelog.yaml
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('company_addresses', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->morphs('addressable');
|
||||
$table->string('label', 80)->default('Registered office');
|
||||
$table->string('attention')->nullable();
|
||||
$table->string('line_1');
|
||||
$table->string('line_2')->nullable();
|
||||
$table->string('line_3')->nullable();
|
||||
$table->string('locality')->comment('City, town or locality.');
|
||||
$table->string('administrative_area')->nullable()->comment('County, state, province or region.');
|
||||
$table->string('postal_code', 32)->nullable();
|
||||
$table->foreignId('country_id')->constrained('countries')->restrictOnDelete();
|
||||
$table->foreignId('country_subdivision_id')->nullable()->constrained('country_subdivisions')->nullOnDelete();
|
||||
$table->decimal('latitude', 10, 7)->nullable();
|
||||
$table->decimal('longitude', 10, 7)->nullable();
|
||||
$table->boolean('is_primary')->default(true);
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['addressable_type', 'addressable_id'], 'company_addresses_one_per_owner_unique');
|
||||
$table->index(['country_id', 'locality']);
|
||||
$table->index(['postal_code']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('company_addresses');
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('company_divisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->string('code', 40)->nullable();
|
||||
$table->string('slug');
|
||||
$table->string('status', 32)->default('active')->index();
|
||||
$table->text('description')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['company_id', 'slug']);
|
||||
$table->unique(['company_id', 'code']);
|
||||
$table->index(['company_id', 'status', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('company_divisions');
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('company_division_user', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
$table->foreignId('company_division_id')->constrained('company_divisions')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('role', 80)->default('member')->index();
|
||||
$table->boolean('is_director')->default(false)->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['company_division_id', 'user_id']);
|
||||
$table->index(['company_id', 'user_id']);
|
||||
$table->index(['company_id', 'is_director']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('company_division_user');
|
||||
}
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('company_director_assignments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
$table->foreignId('company_division_id')->nullable()->constrained('company_divisions')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('coverage_type', 32)->default('company')->index();
|
||||
$table->date('starts_at')->nullable();
|
||||
$table->date('ends_at')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['company_id', 'company_division_id', 'user_id'], 'company_director_coverage_unique');
|
||||
$table->index(['company_id', 'coverage_type']);
|
||||
$table->index(['user_id', 'coverage_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('company_director_assignments');
|
||||
}
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('warehouses', 'division_id')) {
|
||||
$table->foreignId('division_id')->nullable()->after('company_id')->constrained('company_divisions')->nullOnDelete();
|
||||
$table->index(['company_id', 'division_id']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('warehouses', 'division_id')) {
|
||||
$table->dropConstrainedForeignId('division_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
$table->foreignId('country_id')
|
||||
->nullable()
|
||||
->after('billing_email')
|
||||
->constrained('countries')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('country_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Company\Models\CompanyDivision;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
|
||||
class CompanyAddressController extends Controller
|
||||
{
|
||||
public function updateCompany(Request $request, Company $company)
|
||||
{
|
||||
$this->upsertAddress($request, $company);
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Company address updated.');
|
||||
}
|
||||
|
||||
public function updateDivision(Request $request, Company $company, CompanyDivision $division)
|
||||
{
|
||||
abort_unless($division->company_id === $company->id, 404);
|
||||
|
||||
$this->upsertAddress($request, $division, 'Operating address');
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Division address updated.');
|
||||
}
|
||||
|
||||
public function updateWarehouse(Request $request, Company $company, Warehouse $warehouse)
|
||||
{
|
||||
abort_unless($warehouse->company_id === $company->id, 404);
|
||||
|
||||
$this->upsertAddress($request, $warehouse, 'Warehouse address');
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse address updated.');
|
||||
}
|
||||
|
||||
private function upsertAddress(Request $request, object $owner, string $defaultLabel = 'Registered office'): void
|
||||
{
|
||||
$data = $request->validate([
|
||||
'label' => ['nullable', 'string', 'max:80'],
|
||||
'attention' => ['nullable', 'string', 'max:255'],
|
||||
'line_1' => ['required', 'string', 'max:255'],
|
||||
'line_2' => ['nullable', 'string', 'max:255'],
|
||||
'line_3' => ['nullable', 'string', 'max:255'],
|
||||
'locality' => ['required', 'string', 'max:255'],
|
||||
'administrative_area' => ['nullable', 'string', 'max:255'],
|
||||
'postal_code' => ['nullable', 'string', 'max:32'],
|
||||
'country_id' => ['required', Rule::exists('countries', 'id')->where('is_active', true)],
|
||||
'country_subdivision_id' => ['nullable', 'exists:country_subdivisions,id'],
|
||||
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
]);
|
||||
|
||||
$data['label'] = $data['label'] ?: $defaultLabel;
|
||||
$data['is_primary'] = true;
|
||||
|
||||
$owner->address()->updateOrCreate([], $data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Countries\Models\Country;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display companies.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('company::index', [
|
||||
'companies' => Company::query()
|
||||
->with(['address.country', 'country'])
|
||||
->withCount(['users', 'warehouses', 'licenseKeys', 'divisions'])
|
||||
->orderBy('name')
|
||||
->paginate(20),
|
||||
'countries' => $this->countries(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the create company screen.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('company::create', [
|
||||
'company' => new Company([
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
]),
|
||||
'countries' => $this->countries(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a company tenant.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$company = Company::create($this->validated($request));
|
||||
|
||||
return redirect()
|
||||
->route('platform.companies.index')
|
||||
->with('status', 'Company created.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a company workbench.
|
||||
*/
|
||||
public function show(Company $company)
|
||||
{
|
||||
$company->load([
|
||||
'address.country',
|
||||
'country',
|
||||
'divisions.address.country',
|
||||
'divisions.warehouses',
|
||||
'divisions.users',
|
||||
'warehouses.address.country',
|
||||
'warehouses.division',
|
||||
'directorAssignments.user',
|
||||
'directorAssignments.division',
|
||||
])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']);
|
||||
|
||||
return view('company::show', [
|
||||
'company' => $company,
|
||||
'recentUsers' => $company->users()->latest('id')->limit(5)->get(),
|
||||
'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(),
|
||||
'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(),
|
||||
'countries' => $this->countries(),
|
||||
'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']),
|
||||
'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the edit company screen.
|
||||
*/
|
||||
public function edit(Company $company)
|
||||
{
|
||||
return view('company::edit', [
|
||||
'company' => $company->loadMissing(['address.country', 'country']),
|
||||
'countries' => $this->countries(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a company tenant.
|
||||
*/
|
||||
public function update(Request $request, Company $company)
|
||||
{
|
||||
$company->update($this->validated($request, $company));
|
||||
|
||||
return redirect()
|
||||
->route('platform.companies.show', $company)
|
||||
->with('status', 'Company updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a company tenant.
|
||||
*/
|
||||
public function destroy(Company $company)
|
||||
{
|
||||
abort_if($company->is_system_company, 403, 'The system company cannot be deleted.');
|
||||
|
||||
$company->delete();
|
||||
|
||||
return redirect()
|
||||
->route('platform.companies.index')
|
||||
->with('status', 'Company deleted.');
|
||||
}
|
||||
|
||||
private function validated(Request $request, ?Company $company = null): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'billing_email' => ['nullable', 'email', 'max:255'],
|
||||
'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])],
|
||||
'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)],
|
||||
]);
|
||||
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
$data['timezone'] = $company?->timezone ?: 'Europe/London';
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function countries()
|
||||
{
|
||||
return Country::query()
|
||||
->active()
|
||||
->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC")
|
||||
->get(['id', 'alpha2', 'name', 'display_name', 'common_name', 'sort_order']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Company\Models\CompanyDivision;
|
||||
|
||||
class CompanyDivisionController extends Controller
|
||||
{
|
||||
public function store(Request $request, Company $company)
|
||||
{
|
||||
$division = $company->divisions()->create($this->validated($request, $company));
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', "Division {$division->name} created.");
|
||||
}
|
||||
|
||||
public function update(Request $request, Company $company, CompanyDivision $division)
|
||||
{
|
||||
abort_unless($division->company_id === $company->id, 404);
|
||||
|
||||
$division->update($this->validated($request, $company, $division));
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Division updated.');
|
||||
}
|
||||
|
||||
public function destroy(Company $company, CompanyDivision $division)
|
||||
{
|
||||
abort_unless($division->company_id === $company->id, 404);
|
||||
|
||||
abort_if($division->warehouses()->exists(), 422, 'Move warehouses before deleting this division.');
|
||||
|
||||
$division->delete();
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Division deleted.');
|
||||
}
|
||||
|
||||
private function validated(Request $request, Company $company, ?CompanyDivision $division = null): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'code' => ['nullable', 'string', 'max:40', Rule::unique('company_divisions', 'code')->where('company_id', $company->id)->ignore($division?->id)],
|
||||
'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Company\Models\CompanyDivision;
|
||||
use Modules\Company\Models\CompanyDirectorAssignment;
|
||||
|
||||
class CompanyMembershipController extends Controller
|
||||
{
|
||||
public function assignDivision(Request $request, Company $company)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)],
|
||||
'company_division_id' => ['required', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)],
|
||||
'role' => ['nullable', 'string', 'max:80'],
|
||||
'is_director' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$division = CompanyDivision::where('company_id', $company->id)->findOrFail($data['company_division_id']);
|
||||
$division->users()->syncWithoutDetaching([
|
||||
$data['user_id'] => [
|
||||
'company_id' => $company->id,
|
||||
'role' => $data['role'] ?? 'member',
|
||||
'is_director' => (bool) ($data['is_director'] ?? false),
|
||||
],
|
||||
]);
|
||||
|
||||
if ((bool) ($data['is_director'] ?? false)) {
|
||||
CompanyDirectorAssignment::updateOrCreate([
|
||||
'company_id' => $company->id,
|
||||
'company_division_id' => $division->id,
|
||||
'user_id' => $data['user_id'],
|
||||
], ['coverage_type' => 'division']);
|
||||
}
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Division membership updated.');
|
||||
}
|
||||
|
||||
public function assignDirector(Request $request, Company $company)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)],
|
||||
'coverage_type' => ['required', Rule::in(['company', 'division'])],
|
||||
'company_division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)],
|
||||
'starts_at' => ['nullable', 'date'],
|
||||
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
if ($data['coverage_type'] === 'company') {
|
||||
$data['company_division_id'] = null;
|
||||
} else {
|
||||
abort_if(empty($data['company_division_id']), 422, 'Division coverage requires a division.');
|
||||
}
|
||||
|
||||
CompanyDirectorAssignment::updateOrCreate([
|
||||
'company_id' => $company->id,
|
||||
'company_division_id' => $data['company_division_id'],
|
||||
'user_id' => $data['user_id'],
|
||||
], [
|
||||
'coverage_type' => $data['coverage_type'],
|
||||
'starts_at' => $data['starts_at'] ?? null,
|
||||
'ends_at' => $data['ends_at'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage updated.');
|
||||
}
|
||||
|
||||
public function removeDirector(Company $company, CompanyDirectorAssignment $assignment)
|
||||
{
|
||||
abort_unless($assignment->company_id === $company->id, 404);
|
||||
|
||||
$assignment->delete();
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
|
||||
class CompanyWarehouseAssignmentController extends Controller
|
||||
{
|
||||
public function update(Request $request, Company $company, Warehouse $warehouse)
|
||||
{
|
||||
abort_unless($warehouse->company_id === $company->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)],
|
||||
]);
|
||||
|
||||
$warehouse->update(['division_id' => $data['division_id'] ?? null]);
|
||||
|
||||
return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse division assignment updated.');
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
<?php
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Modules\Countries\Models\Country;
|
||||
use Modules\Countries\Models\CountrySubdivision;
|
||||
|
||||
class CompanyAddress extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'addressable_type', 'addressable_id', 'label', 'attention', 'line_1', 'line_2', 'line_3',
|
||||
'locality', 'administrative_area', 'postal_code', 'country_id', 'country_subdivision_id',
|
||||
'latitude', 'longitude', 'is_primary', 'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
];
|
||||
|
||||
public function addressable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function country(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Country::class);
|
||||
}
|
||||
|
||||
public function subdivision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CountrySubdivision::class, 'country_subdivision_id');
|
||||
}
|
||||
|
||||
public function getFormattedAttribute(): string
|
||||
{
|
||||
return collect([
|
||||
$this->attention,
|
||||
$this->line_1,
|
||||
$this->line_2,
|
||||
$this->line_3,
|
||||
$this->locality,
|
||||
$this->administrative_area,
|
||||
$this->postal_code,
|
||||
$this->country?->effective_name ?? $this->country?->name,
|
||||
])->filter()->implode(', ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CompanyDirectorAssignment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id', 'company_division_id', 'user_id', 'coverage_type', 'starts_at', 'ends_at', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'date',
|
||||
'ends_at' => 'date',
|
||||
];
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function division(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CompanyDivision::class, 'company_division_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
|
||||
class CompanyDivision extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'uuid', 'name', 'code', 'slug', 'status', 'description', 'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (CompanyDivision $division): void {
|
||||
$division->uuid ??= (string) Str::uuid();
|
||||
$division->slug ??= Str::slug($division->name);
|
||||
$division->status ??= 'active';
|
||||
});
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function address(): MorphOne
|
||||
{
|
||||
return $this->morphOne(CompanyAddress::class, 'addressable');
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'company_division_user')
|
||||
->withPivot(['company_id', 'role', 'is_director'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function warehouses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Warehouse::class, 'division_id');
|
||||
}
|
||||
|
||||
public function directorAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Company\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CompanyServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadRoutesFrom(__DIR__ . '/../Routes/web.php');
|
||||
$this->loadViewsFrom(__DIR__ . '/../Resources/views', 'company');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
@csrf
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Company name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value="{{ old('name', $company->name) }}"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
required
|
||||
>
|
||||
@error('name')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billing_email" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Billing email</label>
|
||||
<input
|
||||
id="billing_email"
|
||||
type="email"
|
||||
name="billing_email"
|
||||
value="{{ old('billing_email', $company->billing_email) }}"
|
||||
placeholder="billing@example.com"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
>
|
||||
@error('billing_email')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Status</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
required
|
||||
>
|
||||
<option value="active" @selected(old('status', $company->status) === 'active')>Active</option>
|
||||
<option value="suspended" @selected(old('status', $company->status) === 'suspended')>Suspended</option>
|
||||
<option value="inactive" @selected(old('status', $company->status) === 'inactive')>Inactive</option>
|
||||
</select>
|
||||
@error('status')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label for="country_id" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Country</label>
|
||||
<select
|
||||
id="country_id"
|
||||
name="country_id"
|
||||
data-wms-select2
|
||||
data-placeholder="Search and select a country"
|
||||
class="wms-select2-control"
|
||||
>
|
||||
<option value=""></option>
|
||||
@foreach ($countries as $country)
|
||||
<option value="{{ $country->id }}" @selected((int) old('country_id', $company->address?->country_id) === $country->id)>
|
||||
{{ $country->display_name ?: $country->common_name ?: $country->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('country_id')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<button class="rounded-xl bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||
Save company
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="{{ $company->exists ? route('platform.companies.show', $company) : route('platform.companies.index') }}"
|
||||
class="rounded-xl border border-zinc-300 px-4 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<x-layouts::app :title="__('Create company')">
|
||||
<div class="space-y-6">
|
||||
<x-wms.page-hero
|
||||
eyebrow="Platform Administration"
|
||||
title="Create company"
|
||||
description="Add a new tenant company and assign its country."
|
||||
icon="fa-solid fa-building-circle-plus"
|
||||
/>
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<form method="POST" action="{{ route('platform.companies.store') }}">
|
||||
@include('company::_form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,17 @@
|
||||
<x-layouts::app :title="__('Edit company')">
|
||||
<div class="space-y-6">
|
||||
<x-wms.page-hero
|
||||
eyebrow="Platform Administration"
|
||||
title="Edit company"
|
||||
description="Update company details and country assignment."
|
||||
icon="fa-solid fa-building"
|
||||
/>
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<form method="POST" action="{{ route('platform.companies.update', $company) }}">
|
||||
@method('PUT')
|
||||
@include('company::_form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,173 @@
|
||||
<x-layouts::app :title="__('Companies')">
|
||||
<div class="space-y-6">
|
||||
<x-wms.page-hero
|
||||
eyebrow="Platform Administration"
|
||||
title="Companies"
|
||||
description="Manage tenant companies and keep every company’s users, warehouses and records isolated."
|
||||
icon="fa-solid fa-building"
|
||||
/>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="rounded-2xl bg-green-50 p-4 text-sm font-semibold text-green-700 ring-1 ring-green-200 dark:bg-green-950 dark:text-green-300 dark:ring-green-900">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Create company</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Add a new tenant without leaving the administration index.</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="{{ route('platform.companies.create') }}"
|
||||
class="rounded-xl border border-zinc-300 px-4 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Open full form
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('platform.companies.store') }}" class="mt-6">
|
||||
@csrf
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1.15fr)_minmax(12rem,.75fr)_minmax(18rem,1.15fr)_auto] xl:items-end">
|
||||
<div>
|
||||
<label for="quick_company_name" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Company</label>
|
||||
<input
|
||||
id="quick_company_name"
|
||||
name="name"
|
||||
value="{{ old('name') }}"
|
||||
placeholder="Company name"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
required
|
||||
>
|
||||
@error('name')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="quick_billing_email" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Billing email</label>
|
||||
<input
|
||||
id="quick_billing_email"
|
||||
name="billing_email"
|
||||
value="{{ old('billing_email') }}"
|
||||
placeholder="billing@example.com"
|
||||
type="email"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
>
|
||||
@error('billing_email')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="quick_status" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Status</label>
|
||||
<select
|
||||
id="quick_status"
|
||||
name="status"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
required
|
||||
>
|
||||
<option value="active" @selected(old('status', 'active') === 'active')>Active</option>
|
||||
<option value="suspended" @selected(old('status') === 'suspended')>Suspended</option>
|
||||
<option value="inactive" @selected(old('status') === 'inactive')>Inactive</option>
|
||||
</select>
|
||||
@error('status')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label for="quick_country_id" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Country</label>
|
||||
<select
|
||||
id="quick_country_id"
|
||||
name="country_id"
|
||||
data-wms-select2
|
||||
data-placeholder="Search and select country"
|
||||
class="w-full"
|
||||
>
|
||||
<option value=""></option>
|
||||
@foreach($countries as $country)
|
||||
<option value="{{ $country->id }}" @selected(old('country_id') == $country->id)>
|
||||
{{ $country->display_name ?: $country->common_name ?: $country->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('country_id')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<button class="h-12 rounded-xl bg-green-600 px-6 text-sm font-bold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-200 dark:border-zinc-800">
|
||||
<th class="p-4 text-left">Name</th>
|
||||
<th class="p-4 text-left">Users</th>
|
||||
<th class="p-4 text-left">Divisions</th>
|
||||
<th class="p-4 text-left">Warehouses</th>
|
||||
<th class="p-4 text-left">Licences</th>
|
||||
<th class="p-4 text-left">Status</th>
|
||||
<th class="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
@forelse ($companies as $company)
|
||||
<tr class="border-b border-zinc-100 dark:border-zinc-800">
|
||||
<td class="p-4 font-semibold text-zinc-900 dark:text-white">
|
||||
{{ $company->name }}
|
||||
</td>
|
||||
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->users_count }}</td>
|
||||
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->divisions_count ?? 0 }}</td>
|
||||
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->warehouses_count }}</td>
|
||||
<td class="p-4 text-zinc-700 dark:text-zinc-300">{{ $company->license_keys_count }}</td>
|
||||
<td class="p-4">
|
||||
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold
|
||||
@if ($company->is_system_company)
|
||||
bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300
|
||||
@elseif ($company->status === 'active')
|
||||
bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300
|
||||
@else
|
||||
bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300
|
||||
@endif
|
||||
">
|
||||
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a href="{{ route('platform.companies.show', $company) }}" class="rounded-lg border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800">View</a>
|
||||
<a href="{{ route('platform.companies.edit', $company) }}" class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Edit</a>
|
||||
|
||||
@unless ($company->is_system_company)
|
||||
<form method="POST" action="{{ route('platform.companies.destroy', $company) }}" onsubmit="return confirm('Delete this company? This cannot be undone.');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700">Delete</button>
|
||||
</form>
|
||||
@endunless
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="p-8 text-center text-zinc-500 dark:text-zinc-400">No companies found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ $companies->links() }}
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,79 @@
|
||||
@php
|
||||
$address = $address ?? null;
|
||||
$inputClass = 'mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white';
|
||||
$countryFieldId = 'address_country_id_' . md5(($defaultLabel ?? 'address') . '_' . ($address?->id ?? 'new'));
|
||||
@endphp
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="address_label" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Label</label>
|
||||
<input id="address_label" name="label" value="{{ old('label', $address?->label ?? $defaultLabel ?? 'Registered office') }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address_attention" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Attention</label>
|
||||
<input id="address_attention" name="attention" value="{{ old('attention', $address?->attention) }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="address_line_1" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Address line 1</label>
|
||||
<input id="address_line_1" name="line_1" value="{{ old('line_1', $address?->line_1) }}" class="{{ $inputClass }}" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address_line_2" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Address line 2</label>
|
||||
<input id="address_line_2" name="line_2" value="{{ old('line_2', $address?->line_2) }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address_line_3" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Address line 3</label>
|
||||
<input id="address_line_3" name="line_3" value="{{ old('line_3', $address?->line_3) }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address_locality" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Town / City</label>
|
||||
<input id="address_locality" name="locality" value="{{ old('locality', $address?->locality) }}" class="{{ $inputClass }}" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address_administrative_area" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">County / State / Region</label>
|
||||
<input id="address_administrative_area" name="administrative_area" value="{{ old('administrative_area', $address?->administrative_area) }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address_postal_code" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Postcode / ZIP</label>
|
||||
<input id="address_postal_code" name="postal_code" value="{{ old('postal_code', $address?->postal_code) }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label for="{{ $countryFieldId }}" class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
Country
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="{{ $countryFieldId }}"
|
||||
name="country_id"
|
||||
data-wms-select2
|
||||
data-placeholder="Search and select country"
|
||||
class="wms-select2-control"
|
||||
required
|
||||
>
|
||||
<option value=""></option>
|
||||
|
||||
@foreach ($countries as $country)
|
||||
<option
|
||||
value="{{ $country->id }}"
|
||||
@selected((int) old('country_id', $address?->country_id) === $country->id)
|
||||
>
|
||||
{{ $country->display_name ?: $country->common_name ?: $country->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button class="h-12 rounded-xl bg-green-600 px-5 text-sm font-semibold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||
Save address
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,289 @@
|
||||
<x-layouts::app :title="$company->name">
|
||||
<div class="space-y-6">
|
||||
<x-wms.page-hero eyebrow="Company Workbench" :title="$company->name" description="Manage the tenant profile, address, divisions, director coverage and warehouse alignment." icon="fa-solid fa-building" />
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Users</p><p class="mt-2 text-3xl font-bold">{{ $company->users_count }}</p></div>
|
||||
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Divisions</p><p class="mt-2 text-3xl font-bold">{{ $company->divisions_count }}</p></div>
|
||||
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Warehouses</p><p class="mt-2 text-3xl font-bold">{{ $company->warehouses_count }}</p></div>
|
||||
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Licences</p><p class="mt-2 text-3xl font-bold">{{ $company->license_keys_count }}</p></div>
|
||||
<div class="rounded-3xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><p class="text-sm text-zinc-500">Payments</p><p class="mt-2 text-3xl font-bold">{{ $company->payments_count }}</p></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800 xl:col-span-2">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div><h2 class="text-lg font-bold text-zinc-900 dark:text-white">Company profile</h2><p class="mt-1 text-sm text-zinc-500">Core tenant identity and operational status.</p></div>
|
||||
<a href="{{ route('platform.companies.edit', $company) }}" class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white">Edit company</a>
|
||||
</div>
|
||||
<dl class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<div><dt class="text-sm text-zinc-500">Billing email</dt><dd class="font-semibold">{{ $company->billing_email ?? '—' }}</dd></div>
|
||||
<div><dt class="text-sm text-zinc-500">Status</dt><dd class="font-semibold">{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}</dd></div>
|
||||
<div><dt class="text-sm text-zinc-500">Timezone</dt><dd class="font-semibold">{{ $company->timezone }}</dd></div>
|
||||
<div><dt class="text-sm text-zinc-500">Country</dt><dd class="font-semibold">{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: '—' }}</dd></div>
|
||||
<div><dt class="text-sm text-zinc-500">Slug</dt><dd class="font-semibold">{{ $company->slug }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Quick actions</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
<a href="{{ route('platform.companies.index') }}" class="block rounded-xl border border-zinc-300 px-4 py-2 text-center text-sm font-semibold">Back to companies</a>
|
||||
@unless ($company->is_system_company)
|
||||
<form method="POST" action="{{ route('platform.companies.destroy', $company) }}" onsubmit="return confirm('Delete this company? This cannot be undone.');">@csrf @method('DELETE')<button class="w-full rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white">Delete company</button></form>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Company address</h2>
|
||||
@if ($company->address)
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">{{ $company->address->formatted }}</p>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-zinc-500">No company address has been recorded.</p>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('platform.companies.address.update', $company) }}" class="mt-5">
|
||||
@csrf @method('PUT')
|
||||
@include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office'])
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Create division</h2>
|
||||
|
||||
<form method="POST" action="{{ route('platform.companies.divisions.store', $company) }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="division_name" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Division name</label>
|
||||
<input
|
||||
id="division_name"
|
||||
name="name"
|
||||
value="{{ old('name') }}"
|
||||
placeholder="Division name"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="division_code" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Code</label>
|
||||
<input
|
||||
id="division_code"
|
||||
name="code"
|
||||
value="{{ old('code') }}"
|
||||
placeholder="Optional code"
|
||||
class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label for="division_status" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Status</label>
|
||||
<select
|
||||
id="division_status"
|
||||
name="status"
|
||||
data-wms-select2
|
||||
data-placeholder="Select status"
|
||||
class="wms-select2-control"
|
||||
>
|
||||
<option value="active" @selected(old('status', 'active') === 'active')>Active</option>
|
||||
<option value="suspended" @selected(old('status') === 'suspended')>Suspended</option>
|
||||
<option value="inactive" @selected(old('status') === 'inactive')>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button class="h-12 w-full rounded-xl bg-green-600 px-4 text-sm font-bold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||
Create division
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="division_description" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Description</label>
|
||||
<textarea
|
||||
id="division_description"
|
||||
name="description"
|
||||
placeholder="Description"
|
||||
rows="3"
|
||||
class="mt-2 w-full rounded-xl border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
|
||||
>{{ old('description') }}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Director coverage</h2>
|
||||
|
||||
<form method="POST" action="{{ route('platform.companies.director-assignments.store', $company) }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="wms-select2-field">
|
||||
<label for="director_user_id" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">User</label>
|
||||
<select
|
||||
id="director_user_id"
|
||||
name="user_id"
|
||||
data-wms-select2
|
||||
data-placeholder="Select user"
|
||||
class="wms-select2-control"
|
||||
required
|
||||
>
|
||||
<option value="">Select user</option>
|
||||
@foreach($companyUsers as $user)
|
||||
<option value="{{ $user->id }}" @selected(old('user_id') == $user->id)>
|
||||
{{ $user->name }}{{ $user->email ? ' — '.$user->email : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label for="director_coverage_type" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Coverage</label>
|
||||
<select
|
||||
id="director_coverage_type"
|
||||
name="coverage_type"
|
||||
data-wms-select2
|
||||
data-placeholder="Select coverage"
|
||||
class="wms-select2-control"
|
||||
>
|
||||
<option value="company" @selected(old('coverage_type', 'company') === 'company')>Whole company</option>
|
||||
<option value="division" @selected(old('coverage_type') === 'division')>Specific division</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label for="director_company_division_id" class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Division</label>
|
||||
<select
|
||||
id="director_company_division_id"
|
||||
name="company_division_id"
|
||||
data-wms-select2
|
||||
data-placeholder="No division"
|
||||
class="wms-select2-control"
|
||||
>
|
||||
<option value="">No division</option>
|
||||
@foreach($company->divisions->sortBy('name') as $division)
|
||||
<option value="{{ $division->id }}" @selected(old('company_division_id') == $division->id)>{{ $division->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button class="h-12 w-full rounded-xl bg-green-600 px-4 text-sm font-bold text-white shadow-sm shadow-green-950/20 transition hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40">
|
||||
Assign director
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
@forelse($company->directorAssignments as $assignment)
|
||||
<div class="flex items-center justify-between rounded-2xl border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<span>{{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }}</span>
|
||||
<form method="POST" action="{{ route('platform.companies.director-assignments.destroy', [$company, $assignment]) }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="text-sm font-semibold text-red-600">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-500">No director coverage recorded.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Warehouse division and address alignment</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">Warehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.</p>
|
||||
<div class="mt-5 space-y-5">
|
||||
@forelse($company->warehouses as $warehouse)
|
||||
<div class="rounded-2xl border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="font-semibold text-zinc-900 dark:text-white">{{ $warehouse->name }}</p>
|
||||
<p class="text-sm text-zinc-500">{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}</p>
|
||||
@if ($warehouse->address)
|
||||
<p class="mt-2 text-xs text-zinc-500">{{ $warehouse->address->formatted }}</p>
|
||||
@else
|
||||
<p class="mt-2 text-xs text-zinc-500">No warehouse address recorded.</p>
|
||||
@endif
|
||||
</div>
|
||||
<form method="POST" action="{{ route('platform.companies.warehouses.division.update', [$company, $warehouse]) }}" class="contents">
|
||||
@csrf @method('PUT')
|
||||
<select name="division_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<option value="">Company-level warehouse</option>
|
||||
@foreach($company->divisions as $divisionOption)
|
||||
<option value="{{ $divisionOption->id }}" @selected($warehouse->division_id === $divisionOption->id)>{{ $divisionOption->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white">Update assignment</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details class="mt-4 rounded-2xl bg-zinc-50 p-4 dark:bg-zinc-950">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-zinc-900 dark:text-white">Edit warehouse address</summary>
|
||||
<form method="POST" action="{{ route('platform.companies.warehouses.address.update', [$company, $warehouse]) }}" class="mt-4">
|
||||
@csrf @method('PUT')
|
||||
@include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address'])
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-500">No warehouses exist for this company yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-white">Divisions</h2>
|
||||
@forelse($company->divisions as $division)
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><h3 class="text-lg font-bold">{{ $division->name }}</h3><p class="text-sm text-zinc-500">{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses</p></div>
|
||||
<form method="POST" action="{{ route('platform.companies.divisions.destroy', [$company, $division]) }}" onsubmit="return confirm('Delete this division?');">@csrf @method('DELETE')<button class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white">Delete division</button></form>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('platform.companies.divisions.update', [$company, $division]) }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4 md:items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Division name</label>
|
||||
<input name="name" value="{{ $division->name }}" class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Code</label>
|
||||
<input name="code" value="{{ $division->code }}" class="mt-2 h-12 w-full rounded-xl border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="wms-select2-field">
|
||||
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Status</label>
|
||||
<select name="status" data-wms-select2 data-placeholder="Select status" class="wms-select2-control">
|
||||
<option value="active" @selected($division->status === 'active')>Active</option>
|
||||
<option value="suspended" @selected($division->status === 'suspended')>Suspended</option>
|
||||
<option value="inactive" @selected($division->status === 'inactive')>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="h-12 rounded-xl bg-blue-600 px-4 text-sm font-bold text-white shadow-sm transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">Update division</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Description</label>
|
||||
<textarea name="description" rows="3" class="mt-2 w-full rounded-xl border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-white">{{ $division->description }}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-6"><h4 class="font-semibold">Division address</h4><form method="POST" action="{{ route('platform.companies.divisions.address.update', [$company, $division]) }}" class="mt-4">@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])</form></div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-3xl bg-white p-6 text-sm text-zinc-500 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">No divisions exist. Warehouses can remain directly under the company until divisions are introduced.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Modules\Company\Http\Controllers\CompanyAddressController;
|
||||
use Modules\Company\Http\Controllers\CompanyController;
|
||||
use Modules\Company\Http\Controllers\CompanyDivisionController;
|
||||
use Modules\Company\Http\Controllers\CompanyMembershipController;
|
||||
use Modules\Company\Http\Controllers\CompanyWarehouseAssignmentController;
|
||||
|
||||
Route::middleware(['web', 'auth', 'verified', 'tenant.assigned', 'platform.admin'])
|
||||
->prefix('admin')
|
||||
->name('platform.')
|
||||
->group(function () {
|
||||
Route::resource('companies', CompanyController::class);
|
||||
|
||||
Route::put('companies/{company}/address', [CompanyAddressController::class, 'updateCompany'])->name('companies.address.update');
|
||||
Route::post('companies/{company}/divisions', [CompanyDivisionController::class, 'store'])->name('companies.divisions.store');
|
||||
Route::put('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'update'])->name('companies.divisions.update');
|
||||
Route::delete('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'destroy'])->name('companies.divisions.destroy');
|
||||
Route::put('companies/{company}/divisions/{division}/address', [CompanyAddressController::class, 'updateDivision'])->name('companies.divisions.address.update');
|
||||
|
||||
Route::post('companies/{company}/division-memberships', [CompanyMembershipController::class, 'assignDivision'])->name('companies.division-memberships.store');
|
||||
Route::post('companies/{company}/director-assignments', [CompanyMembershipController::class, 'assignDirector'])->name('companies.director-assignments.store');
|
||||
Route::delete('companies/{company}/director-assignments/{assignment}', [CompanyMembershipController::class, 'removeDirector'])->name('companies.director-assignments.destroy');
|
||||
|
||||
Route::put('companies/{company}/warehouses/{warehouse}/division', [CompanyWarehouseAssignmentController::class, 'update'])->name('companies.warehouses.division.update');
|
||||
Route::put('companies/{company}/warehouses/{warehouse}/address', [CompanyAddressController::class, 'updateWarehouse'])->name('companies.warehouses.address.update');
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
latest: 2026.07.08
|
||||
|
||||
releases:
|
||||
2026.07.08:
|
||||
date: 2026-07-08
|
||||
changes:
|
||||
- Hardened platform company creation with unique slug generation for repeated browser-test runs.
|
||||
- Added deterministic Dusk synchronisation for the inline company creation form.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Company",
|
||||
"alias": "company",
|
||||
"version": "1.0.1",
|
||||
"description": "Tenant company administration module.",
|
||||
"keywords": ["company", "tenant", "platform-administration"],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\Company\\Providers\\CompanyServiceProvider"
|
||||
],
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": []
|
||||
}
|
||||
@@ -13,10 +13,7 @@ use Modules\Countries\Support\CountryUsageReport;
|
||||
|
||||
class CountriesController extends Controller
|
||||
{
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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="body"], [data-helpdesk-editor-model="body"]');
|
||||
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="body"], [data-helpdesk-editor-model="body"]');
|
||||
|| textarea.id === 'body';
|
||||
|
||||
const target = document.querySelector('textarea[wire\\:model\\.defer="body"], textarea[name="body"], #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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+458
-401
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
|
||||
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
|
||||
use Modules\Helpdesk\Http\Controllers\TicketControlsController;
|
||||
use Modules\Helpdesk\Http\Controllers\TicketStoreController;
|
||||
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
|
||||
use Modules\Helpdesk\Livewire\KanbanBoard;
|
||||
use Modules\Helpdesk\Livewire\SettingsManager;
|
||||
use Modules\Helpdesk\Livewire\TicketCreate;
|
||||
use Modules\Helpdesk\Livewire\TicketEdit;
|
||||
use Modules\Helpdesk\Livewire\TicketIndex;
|
||||
use Modules\Helpdesk\Livewire\TicketShow;
|
||||
|
||||
Route::get('/workflow/open/{token}', WorkflowOpenController::class)->name('workflow.open');
|
||||
|
||||
Route::get('/', HelpdeskController::class)->name('index');
|
||||
|
||||
Route::get('/settings', SettingsManager::class)->name('settings');
|
||||
Route::get('/kanban', KanbanBoard::class)->name('kanban');
|
||||
|
||||
Route::get('/tickets', TicketIndex::class)->name('tickets.index');
|
||||
Route::post('/tickets', TicketStoreController::class)->name('tickets.store');
|
||||
Route::get('/tickets/create', TicketCreate::class)->name('tickets.create');
|
||||
Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit');
|
||||
Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
|
||||
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
|
||||
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
|
||||
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
|
||||
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
|
||||
->whereNumber('attachment')
|
||||
->name('attachments.download');
|
||||
@@ -5,13 +5,16 @@ namespace Modules\Warehouse\app\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\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;
|
||||
|
||||
Reference in New Issue
Block a user