diff --git a/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php new file mode 100644 index 00000000..627f7f17 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php new file mode 100644 index 00000000..44cad595 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php new file mode 100644 index 00000000..16fed17b --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php new file mode 100644 index 00000000..c539c515 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php new file mode 100644 index 00000000..b7c52b09 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php @@ -0,0 +1,27 @@ +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'); + } + }); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php b/Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php new file mode 100644 index 00000000..0d0a61f0 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_07_093000_add_country_id_to_companies_table.php @@ -0,0 +1,26 @@ +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'); + }); + } +}; diff --git a/Modules/Company/Http/Controllers/CompanyAddressController.php b/Modules/Company/Http/Controllers/CompanyAddressController.php new file mode 100644 index 00000000..abf17a7a --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyAddressController.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php new file mode 100644 index 00000000..1acd93a6 --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -0,0 +1,142 @@ + 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']); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyDivisionController.php b/Modules/Company/Http/Controllers/CompanyDivisionController.php new file mode 100644 index 00000000..baf3b8ed --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyDivisionController.php @@ -0,0 +1,54 @@ +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; + } +} diff --git a/Modules/Company/Http/Controllers/CompanyMembershipController.php b/Modules/Company/Http/Controllers/CompanyMembershipController.php new file mode 100644 index 00000000..7ce6d23c --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyMembershipController.php @@ -0,0 +1,83 @@ +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.'); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php new file mode 100644 index 00000000..194be483 --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php @@ -0,0 +1,25 @@ +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.'); + } +} diff --git a/Modules/Platform/app/Models/Company.php b/Modules/Company/Models/Company.php similarity index 70% rename from Modules/Platform/app/Models/Company.php rename to Modules/Company/Models/Company.php index a05711f8..2437ee75 100644 --- a/Modules/Platform/app/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -1,22 +1,26 @@ 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); diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php new file mode 100644 index 00000000..1345e8fe --- /dev/null +++ b/Modules/Company/Models/CompanyAddress.php @@ -0,0 +1,54 @@ + '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(', '); + } +} diff --git a/Modules/Company/Models/CompanyDirectorAssignment.php b/Modules/Company/Models/CompanyDirectorAssignment.php new file mode 100644 index 00000000..9c2923ea --- /dev/null +++ b/Modules/Company/Models/CompanyDirectorAssignment.php @@ -0,0 +1,34 @@ + '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); + } +} diff --git a/Modules/Company/Models/CompanyDivision.php b/Modules/Company/Models/CompanyDivision.php new file mode 100644 index 00000000..20ea2f29 --- /dev/null +++ b/Modules/Company/Models/CompanyDivision.php @@ -0,0 +1,62 @@ + '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'); + } +} diff --git a/Modules/Company/Providers/CompanyServiceProvider.php b/Modules/Company/Providers/CompanyServiceProvider.php new file mode 100644 index 00000000..d6c4b1bc --- /dev/null +++ b/Modules/Company/Providers/CompanyServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../Routes/web.php'); + $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'company'); + $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations'); + } +} diff --git a/Modules/Company/Resources/views/_form.blade.php b/Modules/Company/Resources/views/_form.blade.php new file mode 100644 index 00000000..c80ec9f9 --- /dev/null +++ b/Modules/Company/Resources/views/_form.blade.php @@ -0,0 +1,83 @@ +@csrf + +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('billing_email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('status') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('country_id') +

{{ $message }}

+ @enderror +
+
+ +
+ + + + Cancel + +
diff --git a/Modules/Company/Resources/views/create.blade.php b/Modules/Company/Resources/views/create.blade.php new file mode 100644 index 00000000..9867415d --- /dev/null +++ b/Modules/Company/Resources/views/create.blade.php @@ -0,0 +1,16 @@ + +
+ + +
+
+ @include('company::_form') +
+
+
+
diff --git a/Modules/Company/Resources/views/edit.blade.php b/Modules/Company/Resources/views/edit.blade.php new file mode 100644 index 00000000..d9413df8 --- /dev/null +++ b/Modules/Company/Resources/views/edit.blade.php @@ -0,0 +1,17 @@ + +
+ + +
+
+ @method('PUT') + @include('company::_form') +
+
+
+
diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php new file mode 100644 index 00000000..1f3661a6 --- /dev/null +++ b/Modules/Company/Resources/views/index.blade.php @@ -0,0 +1,173 @@ + +
+ + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+
+
+

Create company

+

Add a new tenant without leaving the administration index.

+
+ + + Open full form + +
+ +
+ @csrf + +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('billing_email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('status') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('country_id') +

{{ $message }}

+ @enderror +
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + @forelse ($companies as $company) + + + + + + + + + + @empty + + + + @endforelse + +
NameUsersDivisionsWarehousesLicencesStatusActions
+ {{ $company->name }} + {{ $company->users_count }}{{ $company->divisions_count ?? 0 }}{{ $company->warehouses_count }}{{ $company->license_keys_count }} + + {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} + + +
+ View + Edit + + @unless ($company->is_system_company) +
+ @csrf + @method('DELETE') + +
+ @endunless +
+
No companies found.
+
+ + {{ $companies->links() }} +
+
diff --git a/Modules/Company/Resources/views/partials/address-form.blade.php b/Modules/Company/Resources/views/partials/address-form.blade.php new file mode 100644 index 00000000..63ce7586 --- /dev/null +++ b/Modules/Company/Resources/views/partials/address-form.blade.php @@ -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 + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ +
+ +
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php new file mode 100644 index 00000000..3dcb3c60 --- /dev/null +++ b/Modules/Company/Resources/views/show.blade.php @@ -0,0 +1,289 @@ + +
+ + +
+

Users

{{ $company->users_count }}

+

Divisions

{{ $company->divisions_count }}

+

Warehouses

{{ $company->warehouses_count }}

+

Licences

{{ $company->license_keys_count }}

+

Payments

{{ $company->payments_count }}

+
+ +
+
+
+

Company profile

Core tenant identity and operational status.

+ Edit company +
+
+
Billing email
{{ $company->billing_email ?? '—' }}
+
Status
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
+
Timezone
{{ $company->timezone }}
+
Country
{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: '—' }}
+
Slug
{{ $company->slug }}
+
+
+ +
+

Quick actions

+
+ Back to companies + @unless ($company->is_system_company) +
@csrf @method('DELETE')
+ @endunless +
+
+
+ +
+

Company address

+ @if ($company->address) +

{{ $company->address->formatted }}

+ @else +

No company address has been recorded.

+ @endif +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office']) +
+
+ +
+
+

Create division

+ +
+ @csrf + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + +
+
+
+ +
+

Director coverage

+ +
+ @csrf + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+ @forelse($company->directorAssignments as $assignment) +
+ {{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }} +
+ @csrf + @method('DELETE') + +
+
+ @empty +

No director coverage recorded.

+ @endforelse +
+
+
+ + +
+

Warehouse division and address alignment

+

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.

+
+ @forelse($company->warehouses as $warehouse) +
+
+
+

{{ $warehouse->name }}

+

{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}

+ @if ($warehouse->address) +

{{ $warehouse->address->formatted }}

+ @else +

No warehouse address recorded.

+ @endif +
+
+ @csrf @method('PUT') + + +
+
+ +
+ Edit warehouse address +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address']) +
+
+
+ @empty +

No warehouses exist for this company yet.

+ @endforelse +
+
+ +
+

Divisions

+ @forelse($company->divisions as $division) +
+
+

{{ $division->name }}

{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses

+
@csrf @method('DELETE')
+
+
+ @csrf + @method('PUT') + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+

Division address

@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])
+
+ @empty +
No divisions exist. Warehouses can remain directly under the company until divisions are introduced.
+ @endforelse +
+
+
diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php new file mode 100644 index 00000000..ef9d49f6 --- /dev/null +++ b/Modules/Company/Routes/web.php @@ -0,0 +1,27 @@ +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'); + }); diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml new file mode 100644 index 00000000..f71301ee --- /dev/null +++ b/Modules/Company/changelog.yaml @@ -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. diff --git a/Modules/Company/module.json b/Modules/Company/module.json new file mode 100644 index 00000000..75681400 --- /dev/null +++ b/Modules/Company/module.json @@ -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": [] +} diff --git a/Modules/Countries/Http/Controllers/CountriesController.php b/Modules/Countries/Http/Controllers/CountriesController.php index a6179046..e8ff25e7 100644 --- a/Modules/Countries/Http/Controllers/CountriesController.php +++ b/Modules/Countries/Http/Controllers/CountriesController.php @@ -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 - */ 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) { diff --git a/Modules/Countries/Routes/web.php b/Modules/Countries/Routes/web.php index 9bf3c4ee..eaa78263 100644 --- a/Modules/Countries/Routes/web.php +++ b/Modules/Countries/Routes/web.php @@ -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'); diff --git a/Modules/Helpdesk/Database/factories/TicketFactory.php b/Modules/Helpdesk/Database/factories/TicketFactory.php index 3ecdf79c..783a0dfd 100644 --- a/Modules/Helpdesk/Database/factories/TicketFactory.php +++ b/Modules/Helpdesk/Database/factories/TicketFactory.php @@ -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; diff --git a/Modules/Helpdesk/Helpdesk_GitHub_Gitea_Integration_Guide.md b/Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md similarity index 100% rename from Modules/Helpdesk/Helpdesk_GitHub_Gitea_Integration_Guide.md rename to Modules/Helpdesk/Documentation/Helpdesk_GitHub_Gitea_Integration_Guide.md diff --git a/Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php b/Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php deleted file mode 100644 index a3905de5..00000000 --- a/Modules/Helpdesk/Feature/HelpdeskLivewireAssetsTest.php +++ /dev/null @@ -1,27 +0,0 @@ -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()], + ); + } +} diff --git a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php index 705b0249..f28fd4e9 100644 --- a/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php +++ b/Modules/Helpdesk/Livewire/HelpdeskDashboardWidget.php @@ -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(), ]); } } diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index 1b5e9ec5..13dcf90f 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -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') diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php index 77a4d677..a6467145 100644 --- a/Modules/Helpdesk/Livewire/TicketCreate.php +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -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; diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index 8b0f7c58..f7aeeb08 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -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', diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php index 06c25186..c4d0a84d 100644 --- a/Modules/Helpdesk/Livewire/TicketIndex.php +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -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); - } } diff --git a/Modules/Helpdesk/Models/Category.php b/Modules/Helpdesk/Models/Category.php index 2d643b37..936800b9 100644 --- a/Modules/Helpdesk/Models/Category.php +++ b/Modules/Helpdesk/Models/Category.php @@ -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'; diff --git a/Modules/Helpdesk/Models/Project.php b/Modules/Helpdesk/Models/Project.php index 8004fe8c..c0308bdc 100644 --- a/Modules/Helpdesk/Models/Project.php +++ b/Modules/Helpdesk/Models/Project.php @@ -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'; diff --git a/Modules/Helpdesk/Models/Team.php b/Modules/Helpdesk/Models/Team.php index 9152efec..52536a3d 100644 --- a/Modules/Helpdesk/Models/Team.php +++ b/Modules/Helpdesk/Models/Team.php @@ -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; diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php index 7d9a502c..01a91c98 100644 --- a/Modules/Helpdesk/Models/Ticket.php +++ b/Modules/Helpdesk/Models/Ticket.php @@ -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 */ 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 */ public function owner(): BelongsTo { @@ -209,5 +215,4 @@ final class Ticket extends Model { return TicketFactory::new(); } - } diff --git a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php index 8a16c3a1..d1d6f764 100644 --- a/Modules/Helpdesk/Resources/views/components/html-editor.blade.php +++ b/Modules/Helpdesk/Resources/views/components/html-editor.blade.php @@ -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()" >