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 + +
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 }}
+Core tenant identity and operational status.
Divisions
-{{ $company->divisions_count ?? 0 }}
-Warehouses
-{{ $company->warehouses_count ?? 0 }}
-Licences
-{{ $company->license_keys_count ?? 0 }}
+ +{{ $company->address->formatted }}
+ @else +No company address has been recorded.
+ @endif + +No director coverage recorded.
+ @endforelseWarehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.
+{{ $warehouse->name }}
+{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}
+ @if ($warehouse->address) +{{ $warehouse->address->formatted }}
+ @else +No warehouse address recorded.
+ @endif +No warehouses exist for this company yet.
+ @endforelse +{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses