From 60d57af630f9171541e9610c8b16e1c9bbed4868 Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Tue, 26 May 2026 17:41:15 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20departments=20to=20projects=20?= =?UTF-8?q?=E2=80=94=20two-column=20layout=20with=20inline=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/ProjectSettingController.php | 37 +- app/Models/Settings/Department.php | 19 + app/Models/Settings/ProjectSetting.php | 5 + ...3758_create_settings_departments_table.php | 30 ++ .../views/settings/projects/index.blade.php | 324 +++++++++++++++--- routes/web.php | 3 + 6 files changed, 362 insertions(+), 56 deletions(-) create mode 100644 app/Models/Settings/Department.php create mode 100644 database/migrations/2026_05_26_143758_create_settings_departments_table.php diff --git a/app/Http/Controllers/Settings/ProjectSettingController.php b/app/Http/Controllers/Settings/ProjectSettingController.php index 57cd94c..0f23598 100644 --- a/app/Http/Controllers/Settings/ProjectSettingController.php +++ b/app/Http/Controllers/Settings/ProjectSettingController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; +use App\Models\Settings\Department; use App\Models\Settings\Location; use App\Models\Settings\ProjectSetting; use Illuminate\Http\Request; @@ -11,15 +12,17 @@ class ProjectSettingController extends Controller { public function index() { - $projects = ProjectSetting::with(['locations' => function ($q) { - $q->orderBy('name'); - }])->orderBy('name')->get(); + $projects = ProjectSetting::with([ + 'locations' => fn ($q) => $q->orderBy('name'), + 'departments' => fn ($q) => $q->orderBy('name'), + ])->orderBy('name')->get(); $stats = [ - 'total_projects' => $projects->count(), - 'active_projects' => $projects->where('is_active', true)->count(), - 'total_locations' => $projects->sum(fn ($p) => $p->locations->count()), - 'active_locations' => $projects->sum(fn ($p) => $p->locations->where('is_active', true)->count()), + 'total_projects' => $projects->count(), + 'active_projects' => $projects->where('is_active', true)->count(), + 'total_locations' => $projects->sum(fn ($p) => $p->locations->count()), + 'active_locations' => $projects->sum(fn ($p) => $p->locations->where('is_active', true)->count()), + 'total_departments' => $projects->sum(fn ($p) => $p->departments->count()), ]; return view('settings.projects.index', compact('projects', 'stats')); @@ -113,4 +116,24 @@ class ProjectSettingController extends Controller $location->delete(); return response()->json(['ok' => true]); } + + public function storeDepartment(Request $request, ProjectSetting $project) + { + $validated = $request->validate(['name' => 'required|string|max:255']); + $dept = $project->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) + { + $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) + { + $department->delete(); + return response()->json(['ok' => true]); + } } diff --git a/app/Models/Settings/Department.php b/app/Models/Settings/Department.php new file mode 100644 index 0000000..f13bf48 --- /dev/null +++ b/app/Models/Settings/Department.php @@ -0,0 +1,19 @@ + 'boolean']; + + public function project(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(ProjectSetting::class, 'project_id'); + } +} diff --git a/app/Models/Settings/ProjectSetting.php b/app/Models/Settings/ProjectSetting.php index 070bc70..86ac28e 100644 --- a/app/Models/Settings/ProjectSetting.php +++ b/app/Models/Settings/ProjectSetting.php @@ -21,4 +21,9 @@ 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_143758_create_settings_departments_table.php b/database/migrations/2026_05_26_143758_create_settings_departments_table.php new file mode 100644 index 0000000..816c88f --- /dev/null +++ b/database/migrations/2026_05_26_143758_create_settings_departments_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('project_id')->constrained('settings_projects')->cascadeOnDelete(); + $table->string('name', 255); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings_departments'); + } +}; diff --git a/resources/views/settings/projects/index.blade.php b/resources/views/settings/projects/index.blade.php index af8716b..fa5b708 100644 --- a/resources/views/settings/projects/index.blade.php +++ b/resources/views/settings/projects/index.blade.php @@ -8,7 +8,15 @@ .proj-card { border:1px solid #e2e8f0; border-radius:0.875rem; overflow:hidden; margin-bottom:0.75rem; background:white; } .proj-header { display:flex; align-items:center; justify-content:space-between; padding:0.875rem 1.25rem; background:white; } .proj-body { display:block; border-top:1px solid #e2e8f0; } - .loc-row { display:flex; align-items:flex-start; justify-content:space-between; padding:0.75rem 1.25rem 0.75rem 2.5rem; border-bottom:1px solid #f1f5f9; } + .proj-body-inner { display:grid; grid-template-columns:1fr 1fr; } + .proj-section { border-right:1px solid #e2e8f0; } + .proj-section:last-child { border-right:none; } + .proj-section-header { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.25rem; background:#f8fafc; border-bottom:1px solid #e2e8f0; } + .proj-section-title { font-size:11px; font-weight:700; color:#64748b; text-transform:uppercase; letter-spacing:.05em; } + .loc-row { display:flex; align-items:flex-start; justify-content:space-between; padding:0.65rem 1rem 0.65rem 1.25rem; border-bottom:1px solid #f1f5f9; } + .dept-row { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1rem 0.6rem 1.25rem; border-bottom:1px solid #f1f5f9; } + .dept-edit-row { display:none; padding:0.5rem 1rem; background:#f0f9ff; border-bottom:1px solid #bae6fd; gap:6px; align-items:center; } + .dept-edit-row.open { display:flex; } .proj-edit-wrap { display:none; padding:0.75rem 1.25rem; background:#f0f9ff; border-bottom:1px solid #bae6fd; align-items:center; gap:8px; } .proj-edit-wrap.open { display:flex; } .badge-inactive { display:inline-block; padding:1px 7px; border-radius:9px; font-size:11px; font-weight:600; background:#fee2e2; color:#dc2626; margin-left:6px; } @@ -25,13 +33,13 @@ {{-- Page header --}}
-

Projects & Locations

-

Manage projects and their physical sub-locations with addresses and GPS coordinates.

+

Projects, Locations & Departments

+

Manage projects with their sub-locations and departments.

{{-- Stat boxes --}} -
+
@@ -65,6 +73,17 @@
+
+
+
+ +
+
+
{{ $stats['total_departments'] }}
+
Departments
+
+
+
@@ -93,9 +112,10 @@
-{{-- Build location data map for safe JS passing --}} +{{-- Build location + department data maps for safe JS passing --}} @php -$allLocsData = []; +$allLocsData = []; +$allDeptsData = []; foreach ($projects as $proj) { foreach ($proj->locations as $loc) { $allLocsData[$loc->id] = [ @@ -107,8 +127,16 @@ foreach ($projects as $proj) { '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); +$allLocsJson = json_encode($allLocsData); +$allDeptsJson = json_encode($allDeptsData); @endphp {{-- Projects accordion --}} @@ -151,53 +179,115 @@ $allLocsJson = json_encode($allLocsData); {{-- Body --}}
+
- {{-- Location rows (sorted alphabetically by server) --}} -
- @forelse($project->locations as $location) -
-
-
- - - - -
-
- {{ $location->name }} - Inactive -
-
{{ $location->address }}
-
- @if($location->latitude && $location->longitude) - {{ number_format((float)$location->latitude, 6) }}°, {{ number_format((float)$location->longitude, 6) }}° - @endif + {{-- ── Locations column ── --}} +
+
+ + + Locations + + +
+
+ @forelse($project->locations as $location) +
+
+
+ + + + +
+
+ {{ $location->name }} + Inactive +
+
{{ $location->address }}
+
+ @if($location->latitude && $location->longitude) + {{ number_format((float)$location->latitude, 6) }}°, {{ number_format((float)$location->longitude, 6) }}° + @endif +
-
-
- - +
+ + +
+ @empty +
+ No locations yet. +
+ @endforelse
- @empty -
- No locations yet — add the first one below. -
- @endforelse
- {{-- Add Location button --}} -
- + {{-- ── Departments column ── --}} +
+
+ + + Departments + + +
+ + {{-- Inline add row --}} +
+ + + +
+ +
+ @forelse($project->departments as $dept) +
+ {{-- View row --}} +
+
+ + + + {{ $dept->name }} + Inactive +
+
+ + +
+
+ {{-- Inline edit row --}} +
+ + + + +
+
+ @empty +
+ No departments yet. +
+ @endforelse +
+
{{-- end proj-body-inner --}}
{{-- end proj-body --}}
{{-- end proj-card --}} @@ -305,7 +395,8 @@ var CSRF = document.querySelector('meta[name="csrf-token"]').content; var BASE = '{{ url("settings/projects") }}'; // Location data store (keyed by loc ID) — avoids inline JSON in onclick attributes -var LOC_DATA = {!! $allLocsJson !!}; +var LOC_DATA = {!! $allLocsJson !!}; +var DEPT_DATA = {!! $allDeptsJson !!}; // ── Core fetch helper ──────────────────────────────────────────────────────── function api(url, method, data) { @@ -645,12 +736,147 @@ function buildProjectCard(p) { + '

' + '
' + '
' - + '
' - + '
No locations yet — add the first one below.
' + + '
' + + '
' + + '
Locations' + + '
' + + '
No locations yet.
' + + '
' + + '
' + + '
Departments' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
No departments yet.
' + + '
' + '
' - + '
' - + '' + + '
' + + '
'; +} + +// ── Department CRUD ─────────────────────────────────────────────────────────── +function openAddDept(projectId) { + var row = document.getElementById('dept-add-row-' + projectId); + row.classList.add('open'); + document.getElementById('dept-add-name-' + projectId).value = ''; + document.getElementById('dept-add-name-' + projectId).focus(); +} +function closeAddDept(projectId) { + document.getElementById('dept-add-row-' + projectId).classList.remove('open'); +} +function saveDeptAdd(projectId) { + var input = document.getElementById('dept-add-name-' + projectId); + var name = input.value.trim(); + if (!name) { showToast('Department name is required.', 'warn'); return; } + api(BASE + '/' + projectId + '/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); + if (emptyEl) emptyEl.remove(); + var list = document.getElementById('dept-list-' + projectId); + var div = document.createElement('div'); + div.innerHTML = buildDeptRow(projectId, dept); + insertDeptInOrder(projectId, div.firstElementChild); + updateDeptCount(projectId); + 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); + 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(); +} +function closeEditDept(deptId) { + document.getElementById('dept-edit-row-' + deptId).classList.remove('open'); + document.getElementById('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; + if (!name) { showToast('Department name is required.', 'warn'); return; } + api(BASE + '/' + projectId + '/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); + 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'); + showToast('Department updated.', 'success'); + }) + .catch(function(e) { showToast(firstError(e), 'error'); }); +} +function deleteDept(projectId, deptId, name) { + confirmAction('Delete Department', 'Delete "' + name + '"?', function() { + api(BASE + '/' + projectId + '/departments/' + deptId, 'DELETE') + .then(function() { + delete DEPT_DATA[deptId]; + var wrap = document.getElementById('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.
'; + } + showToast('Department "' + esc(name) + '" deleted.', 'success'); + }) + .catch(function(e) { showToast(firstError(e), 'error'); }); + }); +} + +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 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-"]'); + var inserted = false; + for (var i = 0; i < wraps.length; i++) { + var nameEl = wraps[i].querySelector('[id^="dept-name-"]'); + if (nameEl && newName < nameEl.textContent.trim().toLowerCase()) { + list.insertBefore(newWrapEl, wraps[i]); + inserted = true; break; + } + } + if (!inserted) list.appendChild(newWrapEl); +} + +function buildDeptRow(projectId, dept) { + var safeName = (dept.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + return '
' + + '
' + + '
' + + '' + + '' + esc(dept.name) + '' + + '' + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + '
' + '
'; } diff --git a/routes/web.php b/routes/web.php index 31c771d..c16ac64 100644 --- a/routes/web.php +++ b/routes/web.php @@ -141,6 +141,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'); }); });