From 7f8ae898d54d9d2962dbb6d2fcb643d386429fcc Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Mon, 25 May 2026 11:31:12 +0300 Subject: [PATCH] feat: add Projects settings with sub-locations and cascading dropdowns in purchase request modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: add project_id FK to settings_locations - Models: ProjectSetting hasMany Location, Location belongsTo ProjectSetting - Settings: /settings/projects page — manage projects and their sub-locations (two-panel UI) - Sidebar: Projects nav item under Settings group - Routes: 7 new settings/projects routes (Admin only) - Modal: project_name and location fields now cascading dropdowns populated from settings_projects/settings_locations Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 9 + .../Settings/ProjectSettingController.php | 26 +- app/Models/Settings/Location.php | 7 +- app/Models/Settings/ProjectSetting.php | 5 + ...project_id_to_settings_locations_table.php | 30 ++ .../purchase/request-modal.blade.php | 276 +++++++++++++++ resources/views/layouts/app.blade.php | 16 +- .../views/settings/projects/index.blade.php | 320 ++++++++++++++++++ routes/web.php | 11 + 9 files changed, 696 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2026_05_25_000000_add_project_id_to_settings_locations_table.php create mode 100644 resources/views/components/purchase/request-modal.blade.php create mode 100644 resources/views/settings/projects/index.blade.php diff --git a/CLAUDE.md b/CLAUDE.md index d20edf7..87107c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,3 +360,12 @@ To trigger a toast from JavaScript (e.g. after an in-page action), call: showToast('Message text', 'success'); // types: success | error | info | warn ``` **Never** add inline `@if(session('success'))` banner divs to individual views — the layout handles all of them. The toast appears bottom-right, auto-dismisses after 4 s, has a shrinking progress bar, and can be clicked or ×-closed early. + +### 10. Purchase Request creation — always use `` +The create form lives in `resources/views/components/purchase/request-modal.blade.php` as a reusable Blade component. Wherever a "New Purchase Request" trigger is needed, drop in the component tag — it renders the button and the full modal itself: +```blade + +``` +- **Never** link to `route('purchase.requests.create')` for creating new requests — the component replaces that flow entirely. +- The component is self-contained: it owns the trigger button, the Alpine.js open/close state, the full MPR form (POSTing to `purchase.requests.store`), dynamic item rows, and validation-error auto-reopen logic. +- The `/purchase/requests/create` page and route remain as a fallback but should not be referenced in new UI. diff --git a/app/Http/Controllers/Settings/ProjectSettingController.php b/app/Http/Controllers/Settings/ProjectSettingController.php index 2e2f2cc..131aa0b 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\Location; use App\Models\Settings\ProjectSetting; use Illuminate\Http\Request; @@ -10,7 +11,7 @@ class ProjectSettingController extends Controller { public function index() { - $projects = ProjectSetting::orderBy('name')->get(); + $projects = ProjectSetting::with('locations')->orderBy('name')->get(); return view('settings.projects.index', compact('projects')); } @@ -36,4 +37,27 @@ class ProjectSettingController extends Controller $project->delete(); return redirect()->route('settings.projects.index')->with('success', 'Project deleted.'); } + + public function storeLocation(Request $request, ProjectSetting $project) + { + $request->validate(['name' => 'required|string|max:255']); + $project->locations()->create(['name' => $request->name, 'is_active' => true]); + return redirect()->route('settings.projects.index')->with('success', 'Location added.'); + } + + public function updateLocation(Request $request, ProjectSetting $project, Location $location) + { + $request->validate(['name' => 'required|string|max:255']); + $location->update([ + 'name' => $request->name, + 'is_active' => $request->boolean('is_active', true), + ]); + return redirect()->route('settings.projects.index')->with('success', 'Location updated.'); + } + + public function destroyLocation(ProjectSetting $project, Location $location) + { + $location->delete(); + return redirect()->route('settings.projects.index')->with('success', 'Location deleted.'); + } } diff --git a/app/Models/Settings/Location.php b/app/Models/Settings/Location.php index 9bc36ea..25778bf 100644 --- a/app/Models/Settings/Location.php +++ b/app/Models/Settings/Location.php @@ -8,7 +8,7 @@ class Location extends Model { protected $table = 'settings_locations'; - protected $fillable = ['name', 'is_active']; + protected $fillable = ['name', 'project_id', 'is_active']; protected $casts = ['is_active' => 'boolean']; @@ -16,4 +16,9 @@ class Location extends Model { return $query->where('is_active', true); } + + public function project(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(\App\Models\Settings\ProjectSetting::class, 'project_id'); + } } diff --git a/app/Models/Settings/ProjectSetting.php b/app/Models/Settings/ProjectSetting.php index 8ff645d..070bc70 100644 --- a/app/Models/Settings/ProjectSetting.php +++ b/app/Models/Settings/ProjectSetting.php @@ -16,4 +16,9 @@ class ProjectSetting extends Model { return $query->where('is_active', true); } + + 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_25_000000_add_project_id_to_settings_locations_table.php b/database/migrations/2026_05_25_000000_add_project_id_to_settings_locations_table.php new file mode 100644 index 0000000..2c9abde --- /dev/null +++ b/database/migrations/2026_05_25_000000_add_project_id_to_settings_locations_table.php @@ -0,0 +1,30 @@ +foreignId('project_id') + ->nullable() + ->after('name') + ->constrained('settings_projects') + ->nullOnDelete(); + + $table->index('project_id'); + }); + } + + public function down(): void + { + Schema::table('settings_locations', function (Blueprint $table) { + $table->dropIndex(['project_id']); + $table->dropForeign(['project_id']); + $table->dropColumn('project_id'); + }); + } +}; diff --git a/resources/views/components/purchase/request-modal.blade.php b/resources/views/components/purchase/request-modal.blade.php new file mode 100644 index 0000000..fb04d40 --- /dev/null +++ b/resources/views/components/purchase/request-modal.blade.php @@ -0,0 +1,276 @@ +@php +$hasErrors = $errors->any(); +$mprProjects = \App\Models\Settings\ProjectSetting::active()->with(['locations' => function($q){ $q->where('is_active', true)->orderBy('name'); }])->orderBy('name')->get(); +@endphp + +{{-- Trigger button --}} + + +{{-- ── Modal overlay ── --}} + + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8e6ba63..362755b 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -175,11 +175,22 @@ System + + + + + Projects + @@ -412,8 +423,9 @@ function confirmDelete(form, title, body) { var modal = document.getElementById('global-delete-modal'); modal.style.display = 'flex'; document.getElementById('gdm-confirm-btn').onclick = function() { + var form = _gdmForm; closeGlobalDeleteModal(); - _gdmForm.submit(); + form.submit(); }; } diff --git a/resources/views/settings/projects/index.blade.php b/resources/views/settings/projects/index.blade.php new file mode 100644 index 0000000..c1a8418 --- /dev/null +++ b/resources/views/settings/projects/index.blade.php @@ -0,0 +1,320 @@ +@extends('layouts.app') + +@section('title', 'Settings — Projects') + +@section('content') + + +
+

Settings — Projects

+

Manage projects and their sub-locations.

+
+ +{{-- Two-panel layout --}} +
+ + {{-- ── LEFT PANEL: Projects ── --}} +
+
+

+ Projects + {{ $projects->count() }} +

+
+ + {{-- Add Project form --}} +
+
+ @csrf +
+ + @error('name') +

{{ $message }}

+ @enderror +
+ +
+
+ + {{-- Projects list --}} +
+ @forelse($projects as $project) +
+ + {{-- Display row --}} +
+
+ + + + + {{ $project->name }} + + @if(!$project->is_active) + Inactive + @endif + {{ $project->locations->count() }} loc. +
+
+ +
+ @csrf @method('DELETE') + +
+
+
+ + {{-- Edit inline form --}} +
+
+ @csrf @method('PATCH') + + + + +
+
+ +
+
+ @empty +
+ + + + No projects yet. Add one above. +
+ @endforelse +
+
+ + {{-- ── RIGHT PANEL: Locations ── --}} +
+ {{-- Placeholder when nothing selected --}} +
+ + + + +

Select a project to see its locations

+
+ + {{-- Content shown after a project is selected --}} + +
+ +
+ +{{-- Pre-load all project+location data as JSON for client-side rendering --}} + +@endsection diff --git a/routes/web.php b/routes/web.php index 824079b..432d7dd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,8 @@ use App\Http\Controllers\Sales\PaymentReceiptController; use App\Http\Controllers\Sales\SalesInvoiceController; use App\Http\Controllers\Sales\SalesOrderController; use App\Http\Controllers\SettingsController; +use App\Http\Controllers\Settings\ProjectSettingController; +use App\Models\Settings\Location; use Illuminate\Support\Facades\Route; Route::get('/', function () { @@ -120,6 +122,15 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('settings/integrations', [SettingsController::class, 'integrations'])->name('settings.integrations'); Route::post('settings/integrations/whatsapp', [SettingsController::class, 'updateWhatsapp'])->name('settings.integrations.whatsapp'); Route::post('settings/integrations/test-whatsapp', [SettingsController::class, 'testWhatsappConnection'])->name('settings.integrations.test-whatsapp'); + + // 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::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'); + 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'); }); });