From 12e07480a0a85385d34c4a9290a2386e65ff6bf9 Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Tue, 26 May 2026 18:26:27 +0300 Subject: [PATCH] feat: move departments from projects to companies Departments now belong to a Company, not a Project. - New migration: recreates settings_departments with company_id (migrates existing data) - Department model: company_id FK, company() relation - Company model: departments() hasMany relation - ProjectSetting model: removes departments() relation - Controller: dept methods now take Company instead of ProjectSetting - Routes: department CRUD moves to companies/{company}/departments - View: departments section appears on each company card (purple theme); project body is now locations-only (single column) Co-Authored-By: Claude Sonnet 4.6 --- .../Settings/ProjectSettingController.php | 14 +- app/Models/Settings/Company.php | 5 + app/Models/Settings/Department.php | 6 +- app/Models/Settings/ProjectSetting.php | 4 - ...departments_from_projects_to_companies.php | 47 +++ .../views/settings/projects/index.blade.php | 277 +++++++++--------- routes/web.php | 6 +- 7 files changed, 206 insertions(+), 153 deletions(-) create mode 100644 database/migrations/2026_05_26_152123_move_departments_from_projects_to_companies.php diff --git a/app/Http/Controllers/Settings/ProjectSettingController.php b/app/Http/Controllers/Settings/ProjectSettingController.php index 06e3d89..49792d6 100644 --- a/app/Http/Controllers/Settings/ProjectSettingController.php +++ b/app/Http/Controllers/Settings/ProjectSettingController.php @@ -14,8 +14,8 @@ class ProjectSettingController extends Controller public function index() { $companies = Company::with([ - 'projects.locations' => fn ($q) => $q->orderBy('name'), - 'projects.departments' => fn ($q) => $q->orderBy('name'), + 'projects.locations' => fn ($q) => $q->orderBy('name'), + 'departments' => fn ($q) => $q->orderBy('name'), ])->orderBy('name')->get(); $allProjects = $companies->flatMap(fn ($c) => $c->projects); @@ -25,7 +25,7 @@ class ProjectSettingController extends Controller 'total_projects' => $allProjects->count(), 'active_projects' => $allProjects->where('is_active', true)->count(), 'total_locations' => $allProjects->sum(fn ($p) => $p->locations->count()), - 'total_departments' => $allProjects->sum(fn ($p) => $p->departments->count()), + 'total_departments' => $companies->sum(fn ($c) => $c->departments->count()), ]; return view('settings.projects.index', compact('companies', 'stats')); @@ -148,21 +148,21 @@ class ProjectSettingController extends Controller return response()->json(['ok' => true]); } - public function storeDepartment(Request $request, ProjectSetting $project) + public function storeDepartment(Request $request, Company $company) { $validated = $request->validate(['name' => 'required|string|max:255']); - $dept = $project->departments()->create(['name' => $validated['name'], 'is_active' => true]); + $dept = $company->departments()->create(['name' => $validated['name'], 'is_active' => true]); return response()->json(['department' => ['id' => $dept->id, 'name' => $dept->name, 'is_active' => $dept->is_active]]); } - public function updateDepartment(Request $request, ProjectSetting $project, Department $department) + public function updateDepartment(Request $request, Company $company, Department $department) { $validated = $request->validate(['name' => 'required|string|max:255']); $department->update(['name' => $validated['name'], 'is_active' => $request->boolean('is_active', true)]); return response()->json(['department' => ['id' => $department->id, 'name' => $department->name, 'is_active' => $department->is_active]]); } - public function destroyDepartment(ProjectSetting $project, Department $department) + public function destroyDepartment(Company $company, Department $department) { $department->delete(); return response()->json(['ok' => true]); diff --git a/app/Models/Settings/Company.php b/app/Models/Settings/Company.php index 5469524..314b766 100644 --- a/app/Models/Settings/Company.php +++ b/app/Models/Settings/Company.php @@ -16,4 +16,9 @@ class Company extends Model { return $this->hasMany(ProjectSetting::class, 'company_id'); } + + public function departments(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Department::class, 'company_id'); + } } diff --git a/app/Models/Settings/Department.php b/app/Models/Settings/Department.php index f13bf48..ba7fb78 100644 --- a/app/Models/Settings/Department.php +++ b/app/Models/Settings/Department.php @@ -8,12 +8,12 @@ class Department extends Model { protected $table = 'settings_departments'; - protected $fillable = ['name', 'project_id', 'is_active']; + protected $fillable = ['name', 'company_id', 'is_active']; protected $casts = ['is_active' => 'boolean']; - public function project(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo { - return $this->belongsTo(ProjectSetting::class, 'project_id'); + return $this->belongsTo(Company::class, 'company_id'); } } diff --git a/app/Models/Settings/ProjectSetting.php b/app/Models/Settings/ProjectSetting.php index cba90d9..de07cda 100644 --- a/app/Models/Settings/ProjectSetting.php +++ b/app/Models/Settings/ProjectSetting.php @@ -27,8 +27,4 @@ class ProjectSetting extends Model return $this->hasMany(\App\Models\Settings\Location::class, 'project_id'); } - public function departments(): \Illuminate\Database\Eloquent\Relations\HasMany - { - return $this->hasMany(\App\Models\Settings\Department::class, 'project_id'); - } } diff --git a/database/migrations/2026_05_26_152123_move_departments_from_projects_to_companies.php b/database/migrations/2026_05_26_152123_move_departments_from_projects_to_companies.php new file mode 100644 index 0000000..277319c --- /dev/null +++ b/database/migrations/2026_05_26_152123_move_departments_from_projects_to_companies.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('company_id')->constrained('settings_companies')->onDelete('cascade'); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + // Migrate: map each department's project_id → that project's company_id + DB::statement(' + INSERT INTO settings_departments_new (id, company_id, name, is_active, created_at, updated_at) + SELECT d.id, p.company_id, d.name, d.is_active, d.created_at, d.updated_at + FROM settings_departments d + LEFT JOIN settings_projects p ON p.id = d.project_id + WHERE p.company_id IS NOT NULL + '); + + Schema::drop('settings_departments'); + Schema::rename('settings_departments_new', 'settings_departments'); + } + + public function down(): void + { + Schema::create('settings_departments_old', function (Blueprint $table) { + $table->id(); + $table->foreignId('project_id')->constrained('settings_projects')->onDelete('cascade'); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::drop('settings_departments'); + Schema::rename('settings_departments_old', 'settings_departments'); + } +}; diff --git a/resources/views/settings/projects/index.blade.php b/resources/views/settings/projects/index.blade.php index fc653a7..02619f1 100644 --- a/resources/views/settings/projects/index.blade.php +++ b/resources/views/settings/projects/index.blade.php @@ -117,6 +117,14 @@ $allLocsData = []; $allDeptsData = []; foreach ($companies as $company) { + foreach ($company->departments as $dept) { + $allDeptsData[$dept->id] = [ + 'id' => $dept->id, + 'name' => $dept->name, + 'is_active' => $dept->is_active, + 'company_id' => $dept->company_id, + ]; + } foreach ($company->projects as $proj) { foreach ($proj->locations as $loc) { $allLocsData[$loc->id] = [ @@ -128,13 +136,6 @@ foreach ($companies as $company) { 'is_active' => $loc->is_active, ]; } - foreach ($proj->departments as $dept) { - $allDeptsData[$dept->id] = [ - 'id' => $dept->id, - 'name' => $dept->name, - 'is_active' => $dept->is_active, - ]; - } } } $allLocsJson = json_encode($allLocsData); @@ -154,10 +155,13 @@ $allDeptsJson = json_encode($allDeptsData); {{ $company->name }} Inactive {{ $company->projects->count() }} {{ Str::plural('project', $company->projects->count()) }} + · {{ $company->departments->count() }} {{ Str::plural('dept', $company->departments->count()) }}
+ + class="btn-primary" style="padding:4px 12px;font-size:12px;">+ Project
+ {{-- Departments section --}} +
+
+ Departments +
+ {{-- Add dept inline row --}} +
+ + + +
+
+ @forelse($company->departments as $dept) +
+
+
+ + {{ $dept->name }} + Inactive +
+
+ + +
+
+
+ + + + +
+
+ @empty +
No departments yet.
+ @endforelse +
+
+ {{-- Projects container --}}
@@ -227,11 +274,9 @@ $allDeptsJson = json_encode($allDeptsData);

- {{-- Project body: Locations + Departments --}} + {{-- Project body: Locations --}}
-
- {{-- Locations --}} -
+
Locations @@ -264,49 +309,6 @@ $allDeptsJson = json_encode($allDeptsData); @endforelse
- {{-- Departments --}} -
-
- Departments - -
-
- - - -
-
- @forelse($project->departments as $dept) -
-
-
- - {{ $dept->name }} - Inactive -
-
- - -
-
-
- - - - -
-
- @empty -
No departments yet.
- @endforelse -
-
-
{{-- end proj-body-inner --}}
{{-- end proj-body --}}
{{-- end proj-card --}} @empty @@ -832,9 +834,11 @@ function buildCompanyCard(c) { + '' + cName + '' + '' + '0 projects' + + '· 0 depts' + '' + '
' - + '' + + '' + + '' + '' + '' + '
' @@ -846,6 +850,17 @@ function buildCompanyCard(c) { + '' + '

' + '' + + '
' + + '
' + + 'Departments' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
No departments yet.
' + + '
' + '
' + '' + '' @@ -885,22 +900,10 @@ function buildProjectCard(p, companyId) { + '

' + '
' + '
' - + '
' - + '
' - + '
Locations' - + '
' - + '
No locations yet.
' - + '
' - + '
' - + '
Departments' - + '
' - + '
' - + '' - + '' - + '' - + '
' - + '
No departments yet.
' - + '
' + + '
' + + '
Locations' + + '
' + + '
No locations yet.
' + '
' + '
' + '
'; @@ -949,78 +952,80 @@ function insertLocInOrder(projectId, newWrapEl) { if (!inserted) list.appendChild(newWrapEl); } -// ── Department CRUD ─────────────────────────────────────────────────────────── -function openAddDept(projectId) { - var row = document.getElementById('dept-add-row-' + projectId); +// ── Company Department CRUD ─────────────────────────────────────────────────── +var CO_DEPT_BASE = BASE + '/companies'; + +function openAddCoDept(companyId) { + var row = document.getElementById('co-dept-add-row-' + companyId); row.classList.add('open'); - document.getElementById('dept-add-name-' + projectId).value = ''; - document.getElementById('dept-add-name-' + projectId).focus(); + document.getElementById('co-dept-add-name-' + companyId).value = ''; + document.getElementById('co-dept-add-name-' + companyId).focus(); } -function closeAddDept(projectId) { - document.getElementById('dept-add-row-' + projectId).classList.remove('open'); +function closeAddCoDept(companyId) { + document.getElementById('co-dept-add-row-' + companyId).classList.remove('open'); } -function saveDeptAdd(projectId) { - var input = document.getElementById('dept-add-name-' + projectId); +function saveCoDeptAdd(companyId) { + var input = document.getElementById('co-dept-add-name-' + companyId); var name = input.value.trim(); if (!name) { showToast('Department name is required.', 'warn'); return; } - api(BASE + '/' + projectId + '/departments', 'POST', { name: name }) + api(CO_DEPT_BASE + '/' + companyId + '/departments', 'POST', { name: name }) .then(function(data) { var dept = data.department; DEPT_DATA[dept.id] = dept; - closeAddDept(projectId); - var emptyEl = document.getElementById('dept-empty-' + projectId); + closeAddCoDept(companyId); + var emptyEl = document.getElementById('co-dept-empty-' + companyId); if (emptyEl) emptyEl.remove(); - var list = document.getElementById('dept-list-' + projectId); + var list = document.getElementById('co-dept-list-' + companyId); var div = document.createElement('div'); - div.innerHTML = buildDeptRow(projectId, dept); - insertDeptInOrder(projectId, div.firstElementChild); - updateDeptCount(projectId); + div.innerHTML = buildCoDeptRow(companyId, dept); + insertCoDeptInOrder(companyId, div.firstElementChild); + updateCoDeptCount(companyId); showToast('Department "' + esc(dept.name) + '" added.', 'success'); }) .catch(function(e) { showToast(firstError(e), 'error'); }); } -function openEditDept(deptId, projectId, name, isActive) { - document.getElementById('dept-row-' + deptId).style.display = 'none'; - var row = document.getElementById('dept-edit-row-' + deptId); +function openEditCoDept(deptId, companyId, name, isActive) { + document.getElementById('co-dept-row-' + deptId).style.display = 'none'; + var row = document.getElementById('co-dept-edit-row-' + deptId); row.classList.add('open'); - document.getElementById('dept-edit-name-' + deptId).value = name; - document.getElementById('dept-edit-active-' + deptId).checked = isActive; - document.getElementById('dept-edit-name-' + deptId).focus(); + document.getElementById('co-dept-edit-name-' + deptId).value = name; + document.getElementById('co-dept-edit-active-' + deptId).checked = isActive; + document.getElementById('co-dept-edit-name-' + deptId).focus(); } -function closeEditDept(deptId) { - document.getElementById('dept-edit-row-' + deptId).classList.remove('open'); - document.getElementById('dept-row-' + deptId).style.display = ''; +function closeEditCoDept(deptId) { + document.getElementById('co-dept-edit-row-' + deptId).classList.remove('open'); + document.getElementById('co-dept-row-' + deptId).style.display = ''; } -function saveDeptEdit(deptId, projectId) { - var name = document.getElementById('dept-edit-name-' + deptId).value.trim(); - var isActive = document.getElementById('dept-edit-active-' + deptId).checked; +function saveEditCoDept(deptId, companyId) { + var name = document.getElementById('co-dept-edit-name-' + deptId).value.trim(); + var isActive = document.getElementById('co-dept-edit-active-' + deptId).checked; if (!name) { showToast('Department name is required.', 'warn'); return; } - api(BASE + '/' + projectId + '/departments/' + deptId, 'PATCH', { name: name, is_active: isActive ? 1 : 0 }) + api(CO_DEPT_BASE + '/' + companyId + '/departments/' + deptId, 'PATCH', { name: name, is_active: isActive ? 1 : 0 }) .then(function(data) { var dept = data.department; DEPT_DATA[dept.id] = dept; - closeEditDept(deptId); - document.getElementById('dept-name-' + deptId).textContent = dept.name; - var badge = document.getElementById('dept-inactive-' + deptId); + closeEditCoDept(deptId); + document.getElementById('co-dept-name-' + deptId).textContent = dept.name; + var badge = document.getElementById('co-dept-inactive-' + deptId); if (badge) badge.style.display = dept.is_active ? 'none' : ''; - var icon = document.getElementById('dept-icon-' + deptId); - if (icon) icon.setAttribute('stroke', dept.is_active ? '#06b6d4' : '#9ca3af'); + var icon = document.getElementById('co-dept-icon-' + deptId); + if (icon) icon.setAttribute('stroke', dept.is_active ? '#7c3aed' : '#9ca3af'); showToast('Department updated.', 'success'); }) .catch(function(e) { showToast(firstError(e), 'error'); }); } -function deleteDept(projectId, deptId, name) { +function deleteCoDept(companyId, deptId, name) { confirmAction('Delete Department', 'Delete "' + name + '"?', function() { - api(BASE + '/' + projectId + '/departments/' + deptId, 'DELETE') + api(CO_DEPT_BASE + '/' + companyId + '/departments/' + deptId, 'DELETE') .then(function() { delete DEPT_DATA[deptId]; - var wrap = document.getElementById('dept-wrap-' + deptId); + var wrap = document.getElementById('co-dept-wrap-' + deptId); if (wrap) wrap.remove(); - updateDeptCount(projectId); - var list = document.getElementById('dept-list-' + projectId); - if (list && !list.querySelector('[id^="dept-wrap-"]')) { - list.innerHTML = '
No departments yet.
'; + updateCoDeptCount(companyId); + var list = document.getElementById('co-dept-list-' + companyId); + if (list && !list.querySelector('[id^="co-dept-wrap-"]')) { + list.innerHTML = '
No departments yet.
'; } showToast('Department "' + esc(name) + '" deleted.', 'success'); }) @@ -1028,20 +1033,20 @@ function deleteDept(projectId, deptId, name) { }); } -function updateDeptCount(projectId) { - var list = document.getElementById('dept-list-' + projectId); - var count = list ? list.querySelectorAll('[id^="dept-wrap-"]').length : 0; - var el = document.getElementById('proj-dept-count-' + projectId); - if (el) el.textContent = count + ' ' + (count === 1 ? 'dept' : 'depts'); +function updateCoDeptCount(companyId) { + var list = document.getElementById('co-dept-list-' + companyId); + var count = list ? list.querySelectorAll('[id^="co-dept-wrap-"]').length : 0; + var el = document.getElementById('company-dept-count-' + companyId); + if (el) el.textContent = '· ' + count + ' ' + (count === 1 ? 'dept' : 'depts'); } -function insertDeptInOrder(projectId, newWrapEl) { - var list = document.getElementById('dept-list-' + projectId); - var newName = (newWrapEl.querySelector('[id^="dept-name-"]').textContent || '').trim().toLowerCase(); - var wraps = list.querySelectorAll('[id^="dept-wrap-"]'); +function insertCoDeptInOrder(companyId, newWrapEl) { + var list = document.getElementById('co-dept-list-' + companyId); + var newName = (newWrapEl.querySelector('[id^="co-dept-name-"]').textContent || '').trim().toLowerCase(); + var wraps = list.querySelectorAll('[id^="co-dept-wrap-"]'); var inserted = false; for (var i = 0; i < wraps.length; i++) { - var nameEl = wraps[i].querySelector('[id^="dept-name-"]'); + var nameEl = wraps[i].querySelector('[id^="co-dept-name-"]'); if (nameEl && newName < nameEl.textContent.trim().toLowerCase()) { list.insertBefore(newWrapEl, wraps[i]); inserted = true; break; @@ -1050,25 +1055,25 @@ function insertDeptInOrder(projectId, newWrapEl) { if (!inserted) list.appendChild(newWrapEl); } -function buildDeptRow(projectId, dept) { +function buildCoDeptRow(companyId, dept) { var safeName = (dept.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); - return '
' - + '
' + return '
' + + '
' + '
' - + '' - + '' + esc(dept.name) + '' - + '' + + '' + + '' + esc(dept.name) + '' + + '' + '
' + '
' - + '' - + '' + + '' + + '' + '
' + '
' - + '
' - + '' - + '' - + '' - + '' + + '
' + + '' + + '' + + '' + + '' + '
' + '
'; } diff --git a/routes/web.php b/routes/web.php index 228dfe8..c9595f0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -144,9 +144,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('settings/projects/{project}/locations', [ProjectSettingController::class, 'storeLocation'])->name('settings.projects.locations.store'); Route::patch('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'updateLocation'])->name('settings.projects.locations.update'); Route::delete('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'destroyLocation'])->name('settings.projects.locations.destroy'); - Route::post('settings/projects/{project}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.departments.store'); - Route::patch('settings/projects/{project}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.departments.update'); - Route::delete('settings/projects/{project}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.departments.destroy'); + Route::post('settings/projects/companies/{company}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.companies.departments.store'); + Route::patch('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.companies.departments.update'); + Route::delete('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.companies.departments.destroy'); }); });