From ec27834043f72cfa7ed3f9bf941628b95b01b492 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 7 Jul 2026 15:55:17 +0100 Subject: [PATCH] countries selection fixed --- ..._120000_create_company_addresses_table.php | 40 +++ ..._121000_create_company_divisions_table.php | 34 ++ ...000_create_company_division_user_table.php | 30 ++ ...ate_company_director_assignments_table.php | 32 ++ ...00_add_division_id_to_warehouses_table.php | 27 ++ .../Controllers/CompanyAddressController.php | 61 ++++ .../Http/Controllers/CompanyController.php | 66 ++-- .../Controllers/CompanyDivisionController.php | 54 +++ .../CompanyMembershipController.php | 83 +++++ .../CompanyWarehouseAssignmentController.php | 25 ++ Modules/Company/Models/Company.php | 2 +- Modules/Company/Models/CompanyAddress.php | 29 +- .../Models/CompanyDirectorAssignment.php | 19 +- Modules/Company/Models/CompanyDivision.php | 33 +- .../views/partials/address-form.blade.php | 71 ++++ .../Company/Resources/views/show.blade.php | 325 +++++++++++++++--- Modules/Company/Routes/web.php | 32 +- resources/css/app.css | 126 ------- resources/css/wms-select2.css | 125 ++++--- resources/js/app.js | 30 +- 20 files changed, 945 insertions(+), 299 deletions(-) create mode 100644 Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php create mode 100644 Modules/Company/Http/Controllers/CompanyAddressController.php create mode 100644 Modules/Company/Http/Controllers/CompanyDivisionController.php create mode 100644 Modules/Company/Http/Controllers/CompanyMembershipController.php create mode 100644 Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php create mode 100644 Modules/Company/Resources/views/partials/address-form.blade.php 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/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 index 729e98ec..1acd93a6 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -4,7 +4,6 @@ namespace Modules\Company\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Modules\Company\Models\Company; @@ -17,24 +16,12 @@ class CompanyController extends Controller */ public function index() { - $counts = ['users', 'warehouses', 'licenseKeys']; - - if (Schema::hasTable('company_divisions')) { - $counts[] = 'divisions'; - } - - $companies = Company::query() - ->with(['country']) - ->withCount($counts) - ->orderBy('name') - ->paginate(20); - - $companies->getCollection()->each(function (Company $company): void { - $company->setAttribute('divisions_count', $company->getAttribute('divisions_count') ?? 0); - }); - return view('company::index', [ - 'companies' => $companies, + 'companies' => Company::query() + ->with(['address.country', 'country']) + ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) + ->orderBy('name') + ->paginate(20), 'countries' => $this->countries(), ]); } @@ -70,20 +57,17 @@ class CompanyController extends Controller */ public function show(Company $company) { - $loads = ['country']; - $counts = ['users', 'warehouses', 'licenseKeys', 'payments']; - - if (Schema::hasTable('company_divisions')) { - $loads[] = 'divisions'; - $counts[] = 'divisions'; - } - - if (Schema::hasTable('company_director_assignments')) { - $loads[] = 'directorAssignments.user'; - } - - $company->load($loads)->loadCount($counts); - $company->setAttribute('divisions_count', $company->getAttribute('divisions_count') ?? 0); + $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, @@ -91,6 +75,8 @@ class CompanyController extends Controller '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(), ]); } @@ -100,7 +86,7 @@ class CompanyController extends Controller public function edit(Company $company) { return view('company::edit', [ - 'company' => $company->loadMissing('country'), + 'company' => $company->loadMissing(['address.country', 'country']), 'countries' => $this->countries(), ]); } @@ -140,14 +126,10 @@ class CompanyController extends Controller 'country_id' => ['nullable', Rule::exists('countries', 'id')->where('is_active', true)], ]); - return [ - 'name' => $data['name'], - 'billing_email' => $data['billing_email'] ?? null, - 'status' => $data['status'], - 'country_id' => $data['country_id'] ?? null, - 'slug' => Str::slug($data['name']), - 'timezone' => $company?->timezone ?: 'Europe/London', - ]; + $data['slug'] = Str::slug($data['name']); + $data['timezone'] = $company?->timezone ?: 'Europe/London'; + + return $data; } private function countries() @@ -155,6 +137,6 @@ class CompanyController extends Controller return Country::query() ->active() ->orderByRaw("LOWER(COALESCE(NULLIF(display_name, ''), NULLIF(common_name, ''), name)) ASC") - ->get(['id', 'alpha2', 'name', 'display_name', 'common_name']); + ->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/Company/Models/Company.php b/Modules/Company/Models/Company.php index e3dd2a77..2437ee75 100644 --- a/Modules/Company/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -10,8 +10,8 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Modules\Platform\app\Models\LicenseKey; -use Modules\Platform\app\Models\Payment; use Modules\Countries\Models\Country; +use Modules\Platform\app\Models\Payment; use Modules\Warehouse\app\Models\Warehouse; class Company extends Model diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php index 27f84f71..1345e8fe 100644 --- a/Modules/Company/Models/CompanyAddress.php +++ b/Modules/Company/Models/CompanyAddress.php @@ -6,13 +6,21 @@ 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 $guarded = []; + 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 @@ -24,4 +32,23 @@ class CompanyAddress extends Model { 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 index d5ae3435..9c2923ea 100644 --- a/Modules/Company/Models/CompanyDirectorAssignment.php +++ b/Modules/Company/Models/CompanyDirectorAssignment.php @@ -8,20 +8,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class CompanyDirectorAssignment extends Model { - protected $guarded = []; + 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 user(): BelongsTo - { - return $this->belongsTo(User::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 index af5e12dd..20ea2f29 100644 --- a/Modules/Company/Models/CompanyDivision.php +++ b/Modules/Company/Models/CompanyDivision.php @@ -5,13 +5,33 @@ 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 { - protected $guarded = []; + 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 { @@ -23,13 +43,20 @@ class CompanyDivision extends Model 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 users(): HasMany + public function directorAssignments(): HasMany { - return $this->hasMany(User::class, 'division_id'); + return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id'); } } 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..4becfa54 --- /dev/null +++ b/Modules/Company/Resources/views/partials/address-form.blade.php @@ -0,0 +1,71 @@ +@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'; +@endphp + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php index 4e80fa21..3dcb3c60 100644 --- a/Modules/Company/Resources/views/show.blade.php +++ b/Modules/Company/Resources/views/show.blade.php @@ -1,66 +1,289 @@
- + - @if (session('status')) -
- {{ session('status') }} -
- @endif +
+

Users

{{ $company->users_count }}

+

Divisions

{{ $company->divisions_count }}

+

Warehouses

{{ $company->warehouses_count }}

+

Licences

{{ $company->license_keys_count }}

+

Payments

{{ $company->payments_count }}

+
-
-
-

Users

-

{{ $company->users_count ?? 0 }}

+
+
+
+

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 }}
+
-
-

Divisions

-

{{ $company->divisions_count ?? 0 }}

-
-
-

Warehouses

-

{{ $company->warehouses_count ?? 0 }}

-
-
-

Licences

-

{{ $company->license_keys_count ?? 0 }}

+ +
+

Quick actions

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

Company details

-
-
-
Billing email
-
{{ $company->billing_email ?: 'Not set' }}
-
-
-
Country
-
{{ $company->country?->display_name ?: $company->country?->common_name ?: $company->country?->name ?: 'Not set' }}
-
-
-
Status
-
{{ ucfirst($company->status) }}
-
-
-
Timezone
-
{{ $company->timezone }}
-
-
-
+

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']) +
+
-
- Edit - Back +
+
+

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 index 1b4d0da2..ef9d49f6 100644 --- a/Modules/Company/Routes/web.php +++ b/Modules/Company/Routes/web.php @@ -1,13 +1,27 @@ prefix('admin') - ->name('platform.') - ->group(function (): void { - Route::resource('/companies', CompanyController::class); - }); -} +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'); + }); diff --git a/resources/css/app.css b/resources/css/app.css index 11d75bba..7fa64d0d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1696,129 +1696,3 @@ html[data-wms-sidebar="collapsed"] .wms-sidebar { color: rgb(191 219 254); } } - - -/* Company/country Select2 dark override ------------------------------------------------- - Keep the Select2 country field visually consistent with the WMS dark form inputs. */ -.wms-select2-field .select2-container, -.wms-select2-field .select2-container--wms, -.wms-select2-field .select2-container--default { - margin-top: 0.5rem !important; - width: 100% !important; - display: block !important; -} - -.wms-select2-field .select2-container--wms .select2-selection--single, -.wms-select2-field .select2-container--default .select2-selection--single { - height: 3rem !important; - min-height: 3rem !important; - border: 1px solid rgb(63 63 70) !important; - border-radius: 0.75rem !important; - background: rgb(9 9 11) !important; - box-shadow: none !important; - display: flex !important; - align-items: center !important; -} - -.wms-select2-field .select2-container--wms.select2-container--focus .select2-selection--single, -.wms-select2-field .select2-container--wms.select2-container--open .select2-selection--single, -.wms-select2-field .select2-container--default.select2-container--focus .select2-selection--single, -.wms-select2-field .select2-container--default.select2-container--open .select2-selection--single { - border-color: rgb(59 130 246) !important; - box-shadow: 0 0 0 3px rgb(59 130 246 / 0.24) !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__rendered { - width: 100% !important; - padding-left: 1rem !important; - padding-right: 3rem !important; - color: rgb(244 244 245) !important; - font-size: 0.875rem !important; - font-weight: 500 !important; - line-height: 3rem !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__placeholder { - color: rgb(161 161 170) !important; - font-weight: 500 !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__clear { - position: absolute !important; - right: 2.75rem !important; - top: 0.5rem !important; - width: 2rem !important; - height: 2rem !important; - color: rgb(161 161 170) !important; - font-size: 1.2rem !important; - line-height: 1.85rem !important; - text-align: center !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__arrow { - position: absolute !important; - top: 0.5rem !important; - right: 0.625rem !important; - width: 2rem !important; - height: 2rem !important; - border-radius: 0.625rem !important; - background: rgb(39 39 42) !important; -} - -.wms-select2-field .select2-selection--single .select2-selection__arrow b { - border-color: rgb(212 212 216) transparent transparent transparent !important; -} - -.select2-container--wms .select2-dropdown, -.select2-container--default .select2-dropdown { - overflow: hidden !important; - border: 1px solid rgb(63 63 70) !important; - border-radius: 0.875rem !important; - background: rgb(24 24 27) !important; - color: rgb(244 244 245) !important; - box-shadow: 0 24px 60px rgb(0 0 0 / 0.45) !important; -} - -.select2-container--wms .select2-search--dropdown, -.select2-container--default .select2-search--dropdown { - padding: 0.75rem !important; - background: rgb(24 24 27) !important; -} - -.select2-container--wms .select2-search--dropdown .select2-search__field, -.select2-container--default .select2-search--dropdown .select2-search__field { - height: 2.75rem !important; - border: 1px solid rgb(63 63 70) !important; - border-radius: 0.75rem !important; - background: rgb(9 9 11) !important; - color: rgb(244 244 245) !important; - padding: 0 0.875rem !important; - font-size: 0.875rem !important; - outline: none !important; -} - -.select2-container--wms .select2-results__options, -.select2-container--default .select2-results__options { - background: rgb(24 24 27) !important; - max-height: 16rem !important; -} - -.select2-container--wms .select2-results__option, -.select2-container--default .select2-results__option { - padding: 0.75rem 1rem !important; - color: rgb(244 244 245) !important; - font-size: 0.875rem !important; - font-weight: 500 !important; -} - -.select2-container--wms .select2-results__option--highlighted.select2-results__option--selectable, -.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { - background: rgb(37 99 235) !important; - color: white !important; -} - -.select2-container--wms .select2-results__option--selected, -.select2-container--default .select2-results__option--selected { - background: rgb(30 41 59) !important; - color: rgb(191 219 254) !important; -} diff --git a/resources/css/wms-select2.css b/resources/css/wms-select2.css index 20e71182..32dccef5 100644 --- a/resources/css/wms-select2.css +++ b/resources/css/wms-select2.css @@ -1,5 +1,8 @@ -/* WMS Select2 professional dark theme. - This file is imported after Select2's own CSS so it wins reliably. */ +/* WMS Select2 professional dark theme. */ + +.wms-select2-field { + min-width: 0; +} .wms-select2-field .select2-container, .select2-container--wms { @@ -7,7 +10,12 @@ display: block !important; } -.wms-select2-field select[data-wms-select2].select2-hidden-accessible { +.wms-select2-field .select2-container { + margin-top: 0.5rem !important; +} + +.wms-select2-field select[data-wms-select2].select2-hidden-accessible, +.wms-select2-field select.wms-select2-control.select2-hidden-accessible { position: absolute !important; width: 1px !important; height: 1px !important; @@ -19,19 +27,14 @@ margin: -1px !important; } -.wms-select2-field .select2-container { - margin-top: 0.5rem !important; -} - .select2-container--wms .select2-selection--single { + position: relative !important; height: 3rem !important; min-height: 3rem !important; border: 1px solid rgb(63 63 70) !important; border-radius: 0.75rem !important; background: rgb(9 9 11) !important; box-shadow: 0 1px 2px rgb(0 0 0 / 0.2) !important; - display: flex !important; - align-items: center !important; outline: none !important; transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease !important; } @@ -43,8 +46,8 @@ } .select2-container--wms .select2-selection--single .select2-selection__rendered { - width: 100% !important; - padding: 0 3rem 0 1rem !important; + height: 100% !important; + padding: 0 2.75rem 0 1rem !important; color: rgb(244 244 245) !important; font-size: 0.875rem !important; font-weight: 500 !important; @@ -56,43 +59,58 @@ font-weight: 500 !important; } +/* Keep the Select2 arrow as a plain chevron, not a boxed button. */ .select2-container--wms .select2-selection--single .select2-selection__arrow { position: absolute !important; - top: 0.5rem !important; - right: 0.625rem !important; - width: 2rem !important; - height: 2rem !important; - border-radius: 0.625rem !important; - background: rgb(39 39 42) !important; + top: 0 !important; + right: 0.875rem !important; + width: 1.25rem !important; + height: 3rem !important; + background: transparent !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; } .select2-container--wms .select2-selection--single .select2-selection__arrow b { - border-color: rgb(212 212 216) transparent transparent transparent !important; - border-width: 5px 4px 0 4px !important; + position: absolute !important; + left: 50% !important; + top: 50% !important; + width: 0 !important; + height: 0 !important; margin-left: -4px !important; margin-top: -2px !important; + border-style: solid !important; + border-width: 5px 4px 0 4px !important; + border-color: rgb(212 212 216) transparent transparent transparent !important; } .select2-container--wms.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent rgb(212 212 216) transparent !important; + margin-top: -3px !important; border-width: 0 4px 5px 4px !important; + border-color: transparent transparent rgb(212 212 216) transparent !important; } .select2-container--wms .select2-selection--single .select2-selection__clear { position: absolute !important; - right: 2.85rem !important; - top: 0.5rem !important; - width: 2rem !important; - height: 2rem !important; - border-radius: 0.625rem !important; + top: 0 !important; + right: 2.25rem !important; + height: 3rem !important; + width: 1.25rem !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; color: rgb(161 161 170) !important; - font-size: 1.25rem !important; - line-height: 1.85rem !important; + font-size: 1.1rem !important; + font-weight: 700 !important; + line-height: 3rem !important; text-align: center !important; } .select2-container--wms .select2-selection--single .select2-selection__clear:hover { - background: rgb(39 39 42) !important; color: rgb(244 244 245) !important; } @@ -106,21 +124,26 @@ } .select2-container--wms .select2-search--dropdown { - padding: 0.75rem !important; + padding: 0.625rem !important; background: rgb(24 24 27) !important; } .select2-container--wms .select2-search--dropdown .select2-search__field { - height: 2.75rem !important; + height: 2.5rem !important; border: 1px solid rgb(63 63 70) !important; border-radius: 0.75rem !important; background: rgb(9 9 11) !important; color: rgb(244 244 245) !important; padding: 0 0.875rem !important; font-size: 0.875rem !important; + font-weight: 500 !important; outline: none !important; } +.select2-container--wms .select2-search--dropdown .select2-search__field::placeholder { + color: rgb(161 161 170) !important; +} + .select2-container--wms .select2-search--dropdown .select2-search__field:focus { border-color: rgb(59 130 246) !important; box-shadow: 0 0 0 3px rgb(59 130 246 / 0.22) !important; @@ -132,7 +155,7 @@ } .select2-container--wms .select2-results__option { - padding: 0.75rem 1rem !important; + padding: 0.7rem 1rem !important; color: rgb(244 244 245) !important; font-size: 0.875rem !important; font-weight: 500 !important; @@ -148,31 +171,29 @@ color: rgb(191 219 254) !important; } -.select2-container--wms .select2-results__option[aria-disabled='true'] { - color: rgb(113 113 122) !important; -} - +.select2-container--wms .select2-results__option[aria-disabled='true'], .select2-container--wms .select2-results__message { color: rgb(161 161 170) !important; } -/* Safety: any WMS Select2 target should disappear once Select2 has initialised, - even if a view is not wrapped in .wms-select2-field. */ -select[data-wms-select2].select2-hidden-accessible, -select[data-company-country-select2].select2-hidden-accessible, -select[data-helpdesk-select2].select2-hidden-accessible { - position: absolute !important; - width: 1px !important; - height: 1px !important; - overflow: hidden !important; - clip: rect(0 0 0 0) !important; - white-space: nowrap !important; - border: 0 !important; - padding: 0 !important; - margin: -1px !important; +/* Fallback styling before Select2 initialises, matching WMS inputs. */ +.wms-select2-field > select[data-wms-select2], +.wms-select2-field > select.wms-select2-control { + margin-top: 0.5rem; + height: 3rem; + width: 100%; + border-radius: 0.75rem; + border: 1px solid rgb(63 63 70); + background: rgb(9 9 11); + color: rgb(244 244 245); + padding: 0 1rem; + font-size: 0.875rem; + font-weight: 500; } -/* Keep country Select2 controls aligned with the surrounding WMS form fields. */ -.wms-select2-field .select2-container--wms { - margin-top: 0.5rem !important; +.wms-select2-field > select[data-wms-select2]:focus, +.wms-select2-field > select.wms-select2-control:focus { + border-color: rgb(59 130 246); + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.24); + outline: none; } diff --git a/resources/js/app.js b/resources/js/app.js index 93818bcd..37533929 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,11 +7,13 @@ import $ from 'jquery'; window.$ = $; window.jQuery = $; -import attachSelect2 from 'select2'; +import select2Factory from 'select2'; import 'select2/dist/css/select2.min.css'; import '../css/wms-select2.css'; -attachSelect2(window.jQuery); +if (typeof select2Factory === 'function') { + select2Factory(window, $); +} const chartColours = [ 'rgb(8, 145, 178)', @@ -170,25 +172,27 @@ function initialiseSelect2() { return; } - $('[data-helpdesk-select2], [data-wms-select2], [data-company-country-select2]').each(function () { + $('[data-helpdesk-select2], [data-wms-select2], .wms-select2-control').each(function () { const $select = $(this); if ($select.hasClass('select2-hidden-accessible')) { - return; + $select.select2('destroy'); } const isMultiple = $select.prop('multiple'); + const placeholder = + $select.data('placeholder') || + $select.find('option[value=""]').first().text() || + (isMultiple ? 'Select options' : 'Select option'); + $select.select2({ theme: 'wms', width: '100%', allowClear: !isMultiple, closeOnSelect: !isMultiple, dropdownAutoWidth: false, - placeholder: - $select.data('placeholder') || - $select.find('option[value=""]').first().text() || - (isMultiple ? 'Select options' : 'Select option'), + placeholder, sorter(data) { return data.sort((first, second) => first.text.localeCompare(second.text, undefined, { sensitivity: 'base', @@ -197,6 +201,16 @@ function initialiseSelect2() { }, }); + $select.off('select2:open.wms-select2'); + $select.on('select2:open.wms-select2', function () { + const searchField = document.querySelector('.select2-container--open .select2-search__field'); + + if (searchField) { + searchField.placeholder = placeholder; + searchField.focus(); + } + }); + $select.off('change.select2-livewire'); $select.on('change.select2-livewire', function () {