From a425e12349ae157cb12b6bd98d0d0d4c7b37f02f Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Tue, 26 May 2026 18:05:04 +0300 Subject: [PATCH] feat: add companies as top-level container for projects with departments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Company → Project → (Locations + Departments) hierarchy - Company CRUD (add, edit, delete) with AJAX - Projects are created under a company via inline strip - Two-column project body: Locations | Departments - Stats show companies, projects, locations, departments, active projects - Dynamic stat counters update on add/delete without page reload Co-Authored-By: Claude Sonnet 4.6 --- .../Settings/ProjectSettingController.php | 56 +- app/Models/Settings/Company.php | 19 + app/Models/Settings/ProjectSetting.php | 7 +- ...mpanies_and_add_company_id_to_projects.php | 41 ++ .../views/settings/projects/index.blade.php | 656 +++++++++++------- routes/web.php | 3 + 6 files changed, 518 insertions(+), 264 deletions(-) create mode 100644 app/Models/Settings/Company.php create mode 100644 database/migrations/2026_05_26_145521_create_settings_companies_and_add_company_id_to_projects.php diff --git a/app/Http/Controllers/Settings/ProjectSettingController.php b/app/Http/Controllers/Settings/ProjectSettingController.php index 0f23598..a453d72 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\Company; use App\Models\Settings\Department; use App\Models\Settings\Location; use App\Models\Settings\ProjectSetting; @@ -12,33 +13,60 @@ class ProjectSettingController extends Controller { public function index() { - $projects = ProjectSetting::with([ - 'locations' => fn ($q) => $q->orderBy('name'), - 'departments' => fn ($q) => $q->orderBy('name'), + $companies = Company::with([ + 'projects.locations' => fn ($q) => $q->orderBy('name'), + 'projects.departments' => fn ($q) => $q->orderBy('name'), ])->orderBy('name')->get(); + $allProjects = $companies->flatMap(fn ($c) => $c->projects); + $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_departments' => $projects->sum(fn ($p) => $p->departments->count()), + 'total_companies' => $companies->count(), + '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()), ]; - return view('settings.projects.index', compact('projects', 'stats')); + return view('settings.projects.index', compact('companies', 'stats')); } public function store(Request $request) { - $validated = $request->validate(['name' => 'required|string|max:255|unique:settings_projects,name']); - $project = ProjectSetting::create(['name' => $validated['name'], 'is_active' => true]); + $validated = $request->validate([ + 'name' => 'required|string|max:255|unique:settings_projects,name', + 'company_id' => 'required|exists:settings_companies,id', + ]); + $project = ProjectSetting::create(['name' => $validated['name'], 'company_id' => $validated['company_id'], 'is_active' => true]); return response()->json(['project' => [ - 'id' => $project->id, - 'name' => $project->name, - 'is_active' => $project->is_active, + 'id' => $project->id, + 'name' => $project->name, + 'is_active' => $project->is_active, + 'company_id' => $project->company_id, ]]); } + // ── Company CRUD ────────────────────────────────────────────────────────── + public function storeCompany(Request $request) + { + $validated = $request->validate(['name' => 'required|string|max:255|unique:settings_companies,name']); + $company = Company::create(['name' => $validated['name'], 'is_active' => true]); + return response()->json(['company' => ['id' => $company->id, 'name' => $company->name, 'is_active' => $company->is_active]]); + } + + public function updateCompany(Request $request, Company $company) + { + $validated = $request->validate(['name' => 'required|string|max:255|unique:settings_companies,name,' . $company->id]); + $company->update(['name' => $validated['name'], 'is_active' => $request->boolean('is_active', true)]); + return response()->json(['company' => ['id' => $company->id, 'name' => $company->name, 'is_active' => $company->is_active]]); + } + + public function destroyCompany(Company $company) + { + $company->delete(); + return response()->json(['ok' => true]); + } + public function update(Request $request, ProjectSetting $project) { $validated = $request->validate([ diff --git a/app/Models/Settings/Company.php b/app/Models/Settings/Company.php new file mode 100644 index 0000000..5469524 --- /dev/null +++ b/app/Models/Settings/Company.php @@ -0,0 +1,19 @@ + 'boolean']; + + public function projects(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(ProjectSetting::class, 'company_id'); + } +} diff --git a/app/Models/Settings/ProjectSetting.php b/app/Models/Settings/ProjectSetting.php index 86ac28e..cba90d9 100644 --- a/app/Models/Settings/ProjectSetting.php +++ b/app/Models/Settings/ProjectSetting.php @@ -8,7 +8,7 @@ class ProjectSetting extends Model { protected $table = 'settings_projects'; - protected $fillable = ['name', 'is_active']; + protected $fillable = ['name', 'is_active', 'company_id']; protected $casts = ['is_active' => 'boolean']; @@ -17,6 +17,11 @@ class ProjectSetting extends Model return $query->where('is_active', true); } + public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(\App\Models\Settings\Location::class, 'project_id'); diff --git a/database/migrations/2026_05_26_145521_create_settings_companies_and_add_company_id_to_projects.php b/database/migrations/2026_05_26_145521_create_settings_companies_and_add_company_id_to_projects.php new file mode 100644 index 0000000..7a300e1 --- /dev/null +++ b/database/migrations/2026_05_26_145521_create_settings_companies_and_add_company_id_to_projects.php @@ -0,0 +1,41 @@ +id(); + $table->string('name', 255)->unique(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::table('settings_projects', function (Blueprint $table) { + $table->foreignId('company_id')->nullable()->after('id') + ->constrained('settings_companies')->nullOnDelete(); + }); + + // Assign all existing projects to a "General" company + if (\DB::table('settings_projects')->exists()) { + $id = \DB::table('settings_companies')->insertGetId(['name' => 'General', 'is_active' => 1, 'created_at' => now(), 'updated_at' => now()]); + \DB::table('settings_projects')->update(['company_id' => $id]); + } + } + + public function down(): void + { + Schema::table('settings_projects', function (Blueprint $table) { + $table->dropForeign(['company_id']); + $table->dropColumn('company_id'); + }); + Schema::dropIfExists('settings_companies'); + } +}; diff --git a/resources/views/settings/projects/index.blade.php b/resources/views/settings/projects/index.blade.php index fa5b708..e3012f7 100644 --- a/resources/views/settings/projects/index.blade.php +++ b/resources/views/settings/projects/index.blade.php @@ -40,25 +40,25 @@ {{-- Stat boxes --}}
+
+
+
+ +
+
+
{{ $stats['total_companies'] }}
+
Companies
+
+
+
-
{{ $stats['total_projects'] }}
-
Total Projects
-
-
-
-
-
-
- -
-
-
{{ $stats['active_projects'] }}
-
Active Projects
+
{{ $stats['total_projects'] }}
+
Projects
@@ -69,7 +69,7 @@
{{ $stats['total_locations'] }}
-
Total Locations
+
Locations
@@ -84,222 +84,240 @@ -
+
-
- +
+
-
{{ $stats['active_locations'] }}
-
Active Locations
+
{{ $stats['active_projects'] }}
+
Active Projects
-{{-- Add Project --}} +{{-- Add Company --}}
- -

+ +

-
-{{-- Build location + department data maps for safe JS passing --}} +{{-- Build data maps for safe JS passing --}} @php $allLocsData = []; $allDeptsData = []; -foreach ($projects as $proj) { - foreach ($proj->locations as $loc) { - $allLocsData[$loc->id] = [ - 'id' => $loc->id, - 'name' => $loc->name, - 'address' => $loc->address, - 'latitude' => $loc->latitude, - 'longitude' => $loc->longitude, - 'is_active' => $loc->is_active, - ]; - } - foreach ($proj->departments as $dept) { - $allDeptsData[$dept->id] = [ - 'id' => $dept->id, - 'name' => $dept->name, - 'is_active' => $dept->is_active, - ]; +foreach ($companies as $company) { + foreach ($company->projects as $proj) { + foreach ($proj->locations as $loc) { + $allLocsData[$loc->id] = [ + 'id' => $loc->id, + 'name' => $loc->name, + 'address' => $loc->address, + 'latitude' => $loc->latitude, + 'longitude' => $loc->longitude, + '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); $allDeptsJson = json_encode($allDeptsData); @endphp -{{-- Projects accordion --}} -
- @forelse($projects as $project) +{{-- Companies list --}} +
+@forelse($companies as $company) -
- - {{-- Header --}} -
-
- - - - {{ $project->name }} - Inactive - - {{ $project->locations->count() }} {{ Str::plural('location', $project->locations->count()) }} - -
-
- - -
+{{-- Company card --}} +
+ {{-- Company header --}} +
+
+ + {{ $company->name }} + Inactive + {{ $company->projects->count() }} {{ Str::plural('project', $company->projects->count()) }}
- - {{-- Project edit form --}} -
- - - - -

+
+ + +
+
- {{-- Body --}} -
-
+ {{-- Company edit strip --}} +
+ + + + +

+
- {{-- ── Locations column ── --}} -
-
- - - Locations - - + {{-- Add project strip --}} +
+ + + +

+
+ + {{-- Projects container --}} +
+ + @forelse($company->projects as $project) + @php $isLast = $loop->last; @endphp +
+ + {{-- Project header --}} +
+
+ + + + {{ $project->name }} + Inactive
-
- @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 +
+ + +
+
+ + {{-- Project edit strip --}} +
+ + + + +

+
+ + {{-- Project body: Locations + Departments --}} +
+
+ {{-- Locations --}} +
+
+ 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. +
+ {{-- Departments --}} +
+
+ Departments +
- @endforelse -
-
- - {{-- ── Departments column ── --}} -
-
- - - Departments - - -
- - {{-- Inline add row --}} -
- - - -
- -
- @forelse($project->departments as $dept) -
- {{-- View row --}} -
-
- - - - {{ $dept->name }} - Inactive +
+ + + +
+
+ @forelse($project->departments as $dept) +
+
+
+ + {{ $dept->name }} + Inactive +
+
+ + +
-
- - +
+ + + +
- {{-- Inline edit row --}} -
- - - - -
+ @empty +
No departments yet.
+ @endforelse
- @empty -
- No departments yet. -
- @endforelse
-
+
{{-- end proj-body-inner --}} +
{{-- end proj-body --}} +
{{-- end proj-card --}} + @empty +
+ No projects yet — click "+ Add Project" above. +
+ @endforelse +
{{-- end proj-list --}} +
{{-- end company-card --}} -
{{-- end proj-body-inner --}} -
{{-- end proj-body --}} -
{{-- end proj-card --}} - - @empty -
- - - - No projects yet. Add your first project above. -
- @endforelse +@empty +
+ No companies yet. Add your first company above.
+@endforelse +
{{-- end companies-list --}} {{-- ═══════════════ Location Map Modal ═══════════════ --}}
@@ -602,28 +620,111 @@ function saveLocModal() { .catch(function(e) { genErr.textContent = firstError(e); genErr.style.display = 'block'; }); } -// ── Project CRUD ───────────────────────────────────────────────────────────── -function addProject() { - var input = document.getElementById('new-project-input'); - var err = document.getElementById('new-project-error'); +// ── Company CRUD ────────────────────────────────────────────────────────────── +function addCompany() { + var input = document.getElementById('new-company-input'); + var err = document.getElementById('new-company-error'); var name = input.value.trim(); err.style.display = 'none'; - if (!name) { err.textContent = 'Project name is required.'; err.style.display = 'block'; return; } - api(BASE, 'POST', { name: name }) + if (!name) { err.textContent = 'Company name is required.'; err.style.display = 'block'; return; } + api(BASE + '/companies', 'POST', { name: name }) .then(function(data) { input.value = ''; - var p = data.project; - var noMsg = document.getElementById('no-projects-msg'); - if (noMsg) noMsg.remove(); - var list = document.getElementById('projects-list'); - var div = document.createElement('div'); - div.innerHTML = buildProjectCard(p); + var c = data.company; + var list = document.getElementById('companies-list'); + var emptyEl = list.querySelector('.card.card-body'); + if (emptyEl) emptyEl.remove(); + var div = document.createElement('div'); + div.innerHTML = buildCompanyCard(c); list.appendChild(div.firstElementChild); - showToast('Project "' + esc(p.name) + '" added.', 'success'); + updateStat('stat-companies', 1); + showToast('Company "' + esc(c.name) + '" added.', 'success'); }) .catch(function(e) { err.textContent = firstError(e); err.style.display = 'block'; }); } +function openEditCompany(id, name, isActive) { + document.getElementById('company-edit-' + id).classList.add('open'); + document.getElementById('edit-company-name-' + id).value = name; + document.getElementById('edit-company-active-' + id).checked = isActive; + var errEl = document.getElementById('edit-company-error-' + id); + if (errEl) errEl.style.display = 'none'; +} +function closeEditCompany(id) { document.getElementById('company-edit-' + id).classList.remove('open'); } + +function saveCompany(id) { + var name = document.getElementById('edit-company-name-' + id).value.trim(); + var isActive = document.getElementById('edit-company-active-' + id).checked; + var errEl = document.getElementById('edit-company-error-' + id); + errEl.style.display = 'none'; + if (!name) { errEl.textContent = 'Name is required.'; errEl.style.display = 'block'; return; } + api(BASE + '/companies/' + id, 'PATCH', { name: name, is_active: isActive ? 1 : 0 }) + .then(function(data) { + var c = data.company; + closeEditCompany(id); + document.getElementById('company-name-' + id).textContent = c.name; + var badge = document.getElementById('company-inactive-' + id); + if (badge) badge.style.display = c.is_active ? 'none' : ''; + showToast('Company updated.', 'success'); + }) + .catch(function(e) { errEl.textContent = firstError(e); errEl.style.display = 'block'; }); +} + +function deleteCompany(id, name) { + confirmAction('Delete Company', 'Delete "' + name + '" and all its projects?', function() { + api(BASE + '/companies/' + id, 'DELETE') + .then(function() { + var card = document.getElementById('company-card-' + id); + if (card) card.remove(); + var list = document.getElementById('companies-list'); + if (list && !list.querySelector('[id^="company-card-"]')) { + list.innerHTML = '
No companies yet. Add your first company above.
'; + } + updateStat('stat-companies', -1); + showToast('Company "' + esc(name) + '" deleted.', 'success'); + }) + .catch(function(e) { showToast(firstError(e), 'error'); }); + }); +} + +// ── Add Project under Company ───────────────────────────────────────────────── +function openAddProject(companyId) { + var strip = document.getElementById('add-proj-strip-' + companyId); + strip.classList.add('open'); + var input = document.getElementById('add-proj-name-' + companyId); + input.value = ''; + input.focus(); + var errEl = document.getElementById('add-proj-error-' + companyId); + if (errEl) errEl.style.display = 'none'; +} +function closeAddProject(companyId) { + document.getElementById('add-proj-strip-' + companyId).classList.remove('open'); +} +function saveAddProject(companyId) { + var input = document.getElementById('add-proj-name-' + companyId); + var errEl = document.getElementById('add-proj-error-' + companyId); + var name = input.value.trim(); + errEl.style.display = 'none'; + if (!name) { errEl.textContent = 'Project name is required.'; errEl.style.display = 'block'; return; } + api(BASE, 'POST', { name: name, company_id: companyId }) + .then(function(data) { + input.value = ''; + closeAddProject(companyId); + var p = data.project; + var projList = document.getElementById('proj-list-' + companyId); + var noMsg = document.getElementById('no-proj-msg-' + companyId); + if (noMsg) noMsg.remove(); + var div = document.createElement('div'); + div.innerHTML = buildProjectCard(p, companyId); + projList.appendChild(div.firstElementChild); + updateCompanyProjCount(companyId); + updateStat('stat-projects', 1); + showToast('Project "' + esc(p.name) + '" added.', 'success'); + }) + .catch(function(e) { errEl.textContent = firstError(e); errEl.style.display = 'block'; }); +} + +// ── Project CRUD ────────────────────────────────────────────────────────────── function openEditProject(id, name, isActive) { document.getElementById('proj-edit-' + id).classList.add('open'); document.getElementById('edit-proj-name-' + id).value = name; @@ -654,21 +755,123 @@ function saveProject(id) { } function deleteProject(id, name) { - confirmAction('Delete Project', 'Delete "' + name + '" and all its locations?', function() { + confirmAction('Delete Project', 'Delete "' + name + '" and all its locations and departments?', function() { api(BASE + '/' + id, 'DELETE') .then(function() { var card = document.getElementById('proj-card-' + id); - if (card) card.remove(); - if (!document.querySelector('.proj-card')) { - document.getElementById('projects-list').innerHTML = - '
No projects yet. Add your first project above.
'; + if (card) { + var projList = card.closest('[id^="proj-list-"]'); + card.remove(); + if (projList) { + if (!projList.querySelector('[id^="proj-card-"]')) { + var coId = projList.id.replace('proj-list-', ''); + projList.innerHTML = '
No projects yet — click "+ Add Project" above.
'; + } + var coCard = projList.closest('[id^="company-card-"]'); + if (coCard) updateCompanyProjCount(coCard.id.replace('company-card-', '')); + } } + updateStat('stat-projects', -1); showToast('Project "' + esc(name) + '" deleted.', 'success'); }) .catch(function(e) { showToast(firstError(e), 'error'); }); }); } +// ── Stat & count helpers ────────────────────────────────────────────────────── +function updateStat(statId, delta) { + var el = document.getElementById(statId); + if (el) el.textContent = Math.max(0, (parseInt(el.textContent, 10) || 0) + delta); +} + +function updateCompanyProjCount(companyId) { + var projList = document.getElementById('proj-list-' + companyId); + var count = projList ? projList.querySelectorAll('[id^="proj-card-"]').length : 0; + var el = document.getElementById('company-proj-count-' + companyId); + if (el) el.textContent = count + ' ' + (count === 1 ? 'project' : 'projects'); +} + +// ── JS Card Builders ────────────────────────────────────────────────────────── +function buildCompanyCard(c) { + var cName = esc(c.name); + var rawName = (c.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + return '
' + + '
' + + '
' + + '' + + '' + cName + '' + + '' + + '0 projects' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '

' + + '
' + + '
' + + '' + + '' + + '' + + '

' + + '
' + + '
' + + '
No projects yet — click "+ Add Project" above.
' + + '
' + + '
'; +} + +function buildProjectCard(p, companyId) { + var pName = esc(p.name); + var rawName = (p.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + return '
' + + '
' + + '
' + + '' + + '' + pName + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '

' + + '
' + + '
' + + '
' + + '
' + + '
Locations' + + '
' + + '
No locations yet.
' + + '
' + + '
' + + '
Departments' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
No departments yet.
' + + '
' + + '
' + + '
' + + '
'; +} + // ── Location Delete ─────────────────────────────────────────────────────────── function deleteLoc(projectId, locId, name) { confirmAction('Delete Location', 'Delete "' + name + '"?', function() { @@ -712,51 +915,6 @@ function insertLocInOrder(projectId, newWrapEl) { if (!inserted) list.appendChild(newWrapEl); } -function buildProjectCard(p) { - var pName = esc(p.name); - var rawName = p.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); - return '
' - + '
' - + '
' - + '' - + '' + pName + '' - + '' - + '0 locations' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '
' - + '' - + '' - + '' - + '' - + '

' - + '
' - + '
' - + '
' - + '
' - + '
Locations' - + '
' - + '
No locations yet.
' - + '
' - + '
' - + '
Departments' - + '
' - + '
' - + '' - + '' - + '' - + '
' - + '
No departments yet.
' - + '
' - + '
' - + '
' - + '
'; -} - // ── Department CRUD ─────────────────────────────────────────────────────────── function openAddDept(projectId) { var row = document.getElementById('dept-add-row-' + projectId); diff --git a/routes/web.php b/routes/web.php index c16ac64..228dfe8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -136,6 +136,9 @@ Route::middleware(['auth', 'verified'])->group(function () { // Projects settings Route::get('settings/projects', [ProjectSettingController::class, 'index'])->name('settings.projects.index'); Route::post('settings/projects', [ProjectSettingController::class, 'store'])->name('settings.projects.store'); + Route::post('settings/projects/companies', [ProjectSettingController::class, 'storeCompany'])->name('settings.projects.companies.store'); + Route::patch('settings/projects/companies/{company}', [ProjectSettingController::class, 'updateCompany'])->name('settings.projects.companies.update'); + Route::delete('settings/projects/companies/{company}', [ProjectSettingController::class, 'destroyCompany'])->name('settings.projects.companies.destroy'); Route::patch('settings/projects/{project}', [ProjectSettingController::class, 'update'])->name('settings.projects.update'); Route::delete('settings/projects/{project}', [ProjectSettingController::class, 'destroy'])->name('settings.projects.destroy'); Route::post('settings/projects/{project}/locations', [ProjectSettingController::class, 'storeLocation'])->name('settings.projects.locations.store');