feat: add Projects settings with sub-locations and cascading dropdowns in purchase request modal

- 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 <noreply@anthropic.com>
This commit is contained in:
Ghassan Yusuf 2026-05-25 11:31:12 +03:00
parent 75e4890a08
commit 7f8ae898d5
9 changed files with 696 additions and 4 deletions

View File

@ -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 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. **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 `<x-purchase.request-modal />`
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
<x-purchase.request-modal />
```
- **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.

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Settings; namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Settings\Location;
use App\Models\Settings\ProjectSetting; use App\Models\Settings\ProjectSetting;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -10,7 +11,7 @@ class ProjectSettingController extends Controller
{ {
public function index() public function index()
{ {
$projects = ProjectSetting::orderBy('name')->get(); $projects = ProjectSetting::with('locations')->orderBy('name')->get();
return view('settings.projects.index', compact('projects')); return view('settings.projects.index', compact('projects'));
} }
@ -36,4 +37,27 @@ class ProjectSettingController extends Controller
$project->delete(); $project->delete();
return redirect()->route('settings.projects.index')->with('success', 'Project deleted.'); 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.');
}
} }

View File

@ -8,7 +8,7 @@ class Location extends Model
{ {
protected $table = 'settings_locations'; protected $table = 'settings_locations';
protected $fillable = ['name', 'is_active']; protected $fillable = ['name', 'project_id', 'is_active'];
protected $casts = ['is_active' => 'boolean']; protected $casts = ['is_active' => 'boolean'];
@ -16,4 +16,9 @@ class Location extends Model
{ {
return $query->where('is_active', true); return $query->where('is_active', true);
} }
public function project(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Models\Settings\ProjectSetting::class, 'project_id');
}
} }

View File

@ -16,4 +16,9 @@ class ProjectSetting extends Model
{ {
return $query->where('is_active', true); return $query->where('is_active', true);
} }
public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\Settings\Location::class, 'project_id');
}
} }

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('settings_locations', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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 --}}
<button type="button" onclick="mprModalOpen()" class="btn-primary">
+ New Request
</button>
{{-- ── Modal overlay ── --}}
<div id="mpr-modal-overlay"
onclick="if(event.target===this) mprModalClose()"
style="display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;padding:1rem;background:rgba(15,23,42,0.55);backdrop-filter:blur(3px);">
<div style="width:100%;max-width:58rem;max-height:88vh;display:flex;flex-direction:column;background:white;border-radius:1.25rem;box-shadow:0 25px 60px -10px rgba(0,0,0,0.3),0 10px 20px -5px rgba(0,0,0,0.15);">
{{-- ── Header ── --}}
<div style="flex-shrink:0;padding:1.25rem 1.5rem;border-radius:1.25rem 1.25rem 0 0;background:linear-gradient(135deg,#2563eb 0%,#4f46e5 100%);display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:0.875rem;">
<div style="background:rgba(255,255,255,0.15);border-radius:0.625rem;padding:0.5rem;">
<svg style="width:1.25rem;height:1.25rem;stroke:white;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<h2 style="color:white;font-size:1rem;font-weight:700;line-height:1.2;">New Purchase Request</h2>
<p style="color:#bfdbfe;font-size:0.7rem;margin-top:0.1rem;">Material Purchase Request (MPR)</p>
</div>
</div>
<button onclick="mprModalClose()" type="button"
style="color:white;background:rgba(255,255,255,0.15);border:none;border-radius:50%;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:1.25rem;line-height:1;transition:background 0.15s;"
onmouseover="this.style.background='rgba(255,255,255,0.25)'"
onmouseout="this.style.background='rgba(255,255,255,0.15)'">&times;</button>
</div>
{{-- ── Scrollable body ── --}}
<div style="flex:1;overflow-y:auto;padding:1.5rem;">
@if($errors->any())
<div style="margin-bottom:1.25rem;padding:0.875rem 1rem;background:#fef2f2;border:1px solid #fecaca;border-radius:0.75rem;font-size:0.8rem;color:#b91c1c;">
<p style="font-weight:600;margin-bottom:0.25rem;">Please fix the following:</p>
<ul style="list-style:disc;padding-left:1.25rem;line-height:1.8;">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('purchase.requests.store') }}" method="POST" id="mpr-modal-form">
@csrf
{{-- Section: Project / Department --}}
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:0.875rem;padding:1.25rem;margin-bottom:1.25rem;">
<h3 style="font-size:0.7rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:1rem;display:flex;align-items:center;gap:0.4rem;">
<span style="display:inline-block;width:3px;height:12px;background:#6366f1;border-radius:2px;"></span>
Project / Department Details
</h3>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;">
<div>
<label class="form-label">Date <span class="text-red-500">*</span></label>
<input type="date" name="date" value="{{ old('date', date('Y-m-d')) }}" required class="form-input">
</div>
<div>
<label class="form-label">Project / Site Name <span class="text-red-500">*</span></label>
<select name="project_name" id="mpr-project-select" required class="form-input" onchange="mprFilterLocations(this.value)">
<option value=""> Select Project </option>
@foreach($mprProjects as $proj)
<option value="{{ $proj->name }}" {{ old('project_name') == $proj->name ? 'selected' : '' }} data-id="{{ $proj->id }}">{{ $proj->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="form-label">Requested By <span class="text-red-500">*</span></label>
<input type="text" name="requested_by_name" value="{{ old('requested_by_name') }}" required class="form-input" placeholder="Person's name">
</div>
<div>
<label class="form-label">Required Date / Urgency</label>
<input type="text" name="required_date_text" value="{{ old('required_date_text') }}" class="form-input" placeholder="e.g. Urgent, or 2026-06-01">
</div>
<div>
<label class="form-label">Location / Site</label>
<select name="location" id="mpr-location-select" class="form-input">
<option value=""> Select Location </option>
</select>
</div>
<div>
<label class="form-label">Department</label>
<input type="text" name="department" value="{{ old('department') }}" class="form-input" placeholder="e.g. Operations">
</div>
</div>
</div>
{{-- Section: Material Items --}}
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:0.875rem;padding:1.25rem;margin-bottom:1.25rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h3 style="font-size:0.7rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.08em;display:flex;align-items:center;gap:0.4rem;">
<span style="display:inline-block;width:3px;height:12px;background:#6366f1;border-radius:2px;"></span>
Material Details
</h3>
<button type="button" id="mpr-add-row" class="btn-primary btn-sm">+ Add Item</button>
</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:0.8rem;">
<thead>
<tr style="background:white;">
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:2.5rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">#</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Description <span style="color:#f87171;">*</span></th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:5rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Unit</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:6rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Qty <span style="color:#f87171;">*</span></th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:9rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Purpose</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:8rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Req. Date</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.4rem;width:2rem;"></th>
</tr>
</thead>
<tbody id="mpr-items-body">
@php $oldItems = old('items', [[]]); @endphp
@foreach($oldItems as $idx => $oldItem)
<tr class="mpr-item-row" style="background:white;">
<td style="border:1px solid #e2e8f0;padding:0.375rem 0.625rem;text-align:center;color:#cbd5e1;font-size:0.75rem;" class="mpr-row-num">{{ $idx + 1 }}</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="text" name="items[{{ $idx }}][description]" value="{{ $oldItem['description'] ?? '' }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required>
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="text" name="items[{{ $idx }}][unit]" value="{{ $oldItem['unit'] ?? '' }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="PCS…">
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="number" name="items[{{ $idx }}][quantity_required]" value="{{ $oldItem['quantity_required'] ?? '' }}"
min="0.01" step="0.01" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="0" required>
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="text" name="items[{{ $idx }}][purpose_use]" value="{{ $oldItem['purpose_use'] ?? '' }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…">
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="date" name="items[{{ $idx }}][required_date]" value="{{ $oldItem['required_date'] ?? '' }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;">
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;">
<button type="button" class="mpr-remove-row"
style="color:#f87171;background:none;border:none;cursor:pointer;font-size:1.1rem;font-weight:700;line-height:1;padding:0.125rem 0.25rem;border-radius:0.25rem;"
onmouseover="this.style.color='#dc2626'" onmouseout="this.style.color='#f87171'">&times;</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- Section: Remarks --}}
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:0.875rem;padding:1.25rem;">
<h3 style="font-size:0.7rem;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:0.75rem;display:flex;align-items:center;gap:0.4rem;">
<span style="display:inline-block;width:3px;height:12px;background:#6366f1;border-radius:2px;"></span>
Remarks / Notes
</h3>
<textarea name="remarks" rows="2" class="form-textarea">{{ old('remarks') }}</textarea>
</div>
</form>
</div>
{{-- ── Footer ── --}}
<div style="flex-shrink:0;padding:1rem 1.5rem;border-top:1px solid #f1f5f9;border-radius:0 0 1.25rem 1.25rem;background:#f8fafc;display:flex;align-items:center;gap:0.75rem;">
<button type="submit" form="mpr-modal-form" class="btn-primary">Submit Request</button>
<button type="button" onclick="mprModalClose()" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<script>
function mprModalOpen() {
var el = document.getElementById('mpr-modal-overlay');
el.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function mprModalClose() {
var el = document.getElementById('mpr-modal-overlay');
el.style.display = 'none';
document.body.style.overflow = '';
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') mprModalClose();
});
// Auto-open if there are validation errors
@if($errors->any())
document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
@endif
// Dynamic rows
(function () {
var mprRowIndex = {{ count($oldItems ?? [[]]) }};
function mprRenumber() {
document.querySelectorAll('#mpr-items-body .mpr-row-num').forEach(function (el, i) {
el.textContent = i + 1;
});
}
function mprNewRow() {
var idx = mprRowIndex++;
var tr = document.createElement('tr');
tr.className = 'mpr-item-row';
tr.style.background = 'white';
tr.innerHTML =
'<td style="border:1px solid #e2e8f0;padding:0.375rem 0.625rem;text-align:center;color:#cbd5e1;font-size:0.75rem;" class="mpr-row-num"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][description]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="PCS…"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="number" name="items[' + idx + '][quantity_required]" min="0.01" step="0.01" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="0" required></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][purpose_use]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="date" name="items[' + idx + '][required_date]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;"><button type="button" class="mpr-remove-row" style="color:#f87171;background:none;border:none;cursor:pointer;font-size:1.1rem;font-weight:700;line-height:1;" onmouseover="this.style.color=\'#dc2626\'" onmouseout="this.style.color=\'#f87171\'">&times;</button></td>';
return tr;
}
document.addEventListener('DOMContentLoaded', function () {
var addBtn = document.getElementById('mpr-add-row');
var tbody = document.getElementById('mpr-items-body');
if (!addBtn || !tbody) return;
addBtn.addEventListener('click', function () {
tbody.appendChild(mprNewRow());
mprRenumber();
});
tbody.addEventListener('click', function (e) {
if (e.target.classList.contains('mpr-remove-row')) {
if (tbody.querySelectorAll('.mpr-item-row').length > 1) {
e.target.closest('tr').remove();
mprRenumber();
}
}
});
mprRenumber();
});
})();
// Cascading project → location dropdown
var mprProjectsData = @json($mprProjects->map(fn($p) => ['id' => $p->id, 'name' => $p->name, 'locations' => $p->locations->map(fn($l) => ['name' => $l->name])->values()])->values());
var mprOldLocation = "{{ old('location') }}";
var mprOldProjectName = "{{ old('project_name') }}";
function mprFilterLocations(projectName) {
var sel = document.getElementById('mpr-location-select');
sel.innerHTML = '<option value="">— Select Location —</option>';
if (!projectName) { sel.disabled = true; return; }
var proj = mprProjectsData.find(function(p){ return p.name === projectName; });
if (proj && proj.locations.length) {
proj.locations.forEach(function(loc){
var opt = document.createElement('option');
opt.value = loc.name;
opt.textContent = loc.name;
if (loc.name === mprOldLocation) opt.selected = true;
sel.appendChild(opt);
});
sel.disabled = false;
} else {
sel.disabled = true;
}
}
// On load: if old project value exists (validation error repopulation), trigger filter
document.addEventListener('DOMContentLoaded', function(){
if (mprOldProjectName) {
mprFilterLocations(mprOldProjectName);
} else {
document.getElementById('mpr-location-select').disabled = true;
}
});
</script>

View File

@ -175,11 +175,22 @@
System System
</span> </span>
</div> </div>
<a href="{{ route('settings.projects.index') }}" style="
display:flex; align-items:center; gap:8px;
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
font-size:13px; text-decoration:none;
{{ request()->routeIs('settings.projects.*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Projects
</a>
<a href="{{ route('settings.integrations') }}" style=" <a href="{{ route('settings.integrations') }}" style="
display:flex; align-items:center; gap:8px; display:flex; align-items:center; gap:8px;
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px; padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
font-size:13px; text-decoration:none; font-size:13px; text-decoration:none;
{{ request()->routeIs('settings.*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }} {{ request()->routeIs('settings.integrations*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'"> " onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;"> <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
@ -412,8 +423,9 @@ function confirmDelete(form, title, body) {
var modal = document.getElementById('global-delete-modal'); var modal = document.getElementById('global-delete-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
document.getElementById('gdm-confirm-btn').onclick = function() { document.getElementById('gdm-confirm-btn').onclick = function() {
var form = _gdmForm;
closeGlobalDeleteModal(); closeGlobalDeleteModal();
_gdmForm.submit(); form.submit();
}; };
} }

View File

@ -0,0 +1,320 @@
@extends('layouts.app')
@section('title', 'Settings — Projects')
@section('content')
<style>
.proj-row { cursor:pointer; transition:background .15s; }
.proj-row:hover { background:#f8fafc; }
.proj-row.selected { background:#eff6ff; }
.loc-row { transition:background .15s; }
.loc-row:hover { background:#f8fafc; }
.edit-form { display:none; }
.edit-form.open { display:flex; }
.badge-active { display:inline-block; padding:2px 8px; border-radius:9px; font-size:11px; font-weight:600; background:#dcfce7; color:#16a34a; }
.badge-inactive { display:inline-block; padding:2px 8px; border-radius:9px; font-size:11px; font-weight:600; background:#fee2e2; color:#dc2626; }
</style>
<div class="mb-6">
<h1 class="page-title">Settings Projects</h1>
<p class="page-subtitle">Manage projects and their sub-locations.</p>
</div>
{{-- Two-panel layout --}}
<div style="display:grid; grid-template-columns:1fr 1fr; gap:24px; align-items:start;">
{{-- ── LEFT PANEL: Projects ── --}}
<div class="card">
<div style="padding:16px 20px 14px; border-bottom:1px solid #e5e7eb; display:flex; align-items:center; justify-content:space-between;">
<h3 style="font-size:15px; font-weight:600; color:#111827; margin:0;">
Projects
<span style="font-size:12px; font-weight:400; color:#6b7280; margin-left:6px;">{{ $projects->count() }}</span>
</h3>
</div>
{{-- Add Project form --}}
<div style="padding:16px 20px; border-bottom:1px solid #f3f4f6;">
<form method="POST" action="{{ route('settings.projects.store') }}" style="display:flex; gap:8px; align-items:flex-start;">
@csrf
<div style="flex:1;">
<input type="text" name="name" value="{{ old('name') }}"
placeholder="New project name…"
class="form-input @error('name') border-red-400 @enderror"
style="width:100%;">
@error('name')
<p style="color:#dc2626; font-size:12px; margin-top:4px;">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="btn-primary" style="white-space:nowrap; flex-shrink:0;">Add Project</button>
</form>
</div>
{{-- Projects list --}}
<div style="padding:8px 0;">
@forelse($projects as $project)
<div class="proj-row" id="proj-row-{{ $project->id }}" data-id="{{ $project->id }}" onclick="selectProject({{ $project->id }})">
{{-- Display row --}}
<div class="proj-display-{{ $project->id }}" style="display:flex; align-items:center; justify-content:space-between; padding:10px 16px;">
<div style="display:flex; align-items:center; gap:10px; flex:1; min-width:0;">
<svg width="14" height="14" fill="none" stroke="{{ $project->is_active ? '#22c55e' : '#9ca3af' }}" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span style="font-size:13.5px; color:#1e293b; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
{{ $project->name }}
</span>
@if(!$project->is_active)
<span class="badge-inactive">Inactive</span>
@endif
<span style="font-size:11px; color:#94a3b8; flex-shrink:0;">{{ $project->locations->count() }} loc.</span>
</div>
<div style="display:flex; gap:4px; flex-shrink:0;" onclick="event.stopPropagation()">
<button type="button"
onclick="openEditProject({{ $project->id }}, '{{ addslashes($project->name) }}', {{ $project->is_active ? 'true' : 'false' }})"
class="btn-secondary btn-sm" style="padding:3px 8px; font-size:12px;">Edit</button>
<form method="POST" action="{{ route('settings.projects.destroy', $project) }}" id="del-proj-{{ $project->id }}">
@csrf @method('DELETE')
<button type="button"
onclick="confirmDelete(document.getElementById('del-proj-{{ $project->id }}'), 'Delete Project', 'Delete &quot;{{ addslashes($project->name) }}&quot; and all its locations?')"
class="btn-danger btn-sm" style="padding:3px 8px; font-size:12px;">Delete</button>
</form>
</div>
</div>
{{-- Edit inline form --}}
<div class="edit-form proj-edit-{{ $project->id }}" style="padding:10px 16px; background:#f8fafc; border-top:1px solid #e5e7eb; gap:8px; align-items:flex-start;" onclick="event.stopPropagation()">
<form method="POST" action="{{ route('settings.projects.update', $project) }}" style="display:flex; gap:8px; align-items:center; flex:1;">
@csrf @method('PATCH')
<input type="text" name="name" id="edit-proj-name-{{ $project->id }}"
class="form-input" style="flex:1; font-size:13px;">
<label style="display:flex; align-items:center; gap:4px; font-size:12px; color:#374151; white-space:nowrap; cursor:pointer;">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" id="edit-proj-active-{{ $project->id }}"
style="width:14px; height:14px; cursor:pointer;">
Active
</label>
<button type="submit" class="btn-primary" style="padding:5px 12px; font-size:12px; white-space:nowrap;">Save</button>
<button type="button" onclick="closeEditProject({{ $project->id }})"
class="btn-secondary btn-sm" style="padding:5px 10px; font-size:12px;">Cancel</button>
</form>
</div>
</div>
<div style="height:1px; background:#f3f4f6; margin:0 16px;"></div>
@empty
<div style="padding:32px 20px; text-align:center; color:#9ca3af; font-size:13.5px;">
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="margin:0 auto 8px; display:block; opacity:.4;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
No projects yet. Add one above.
</div>
@endforelse
</div>
</div>
{{-- ── RIGHT PANEL: Locations ── --}}
<div class="card" id="locations-panel">
{{-- Placeholder when nothing selected --}}
<div id="loc-placeholder" style="padding:48px 24px; text-align:center; color:#9ca3af;">
<svg width="36" height="36" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="margin:0 auto 10px; display:block; opacity:.35;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p style="font-size:13.5px; margin:0;">Select a project to see its locations</p>
</div>
{{-- Content shown after a project is selected --}}
<div id="loc-content" style="display:none;">
<div style="padding:16px 20px 14px; border-bottom:1px solid #e5e7eb; display:flex; align-items:center; justify-content:space-between;">
<h3 style="font-size:15px; font-weight:600; color:#111827; margin:0;">
Locations <span id="loc-project-name" style="color:#2563eb;"></span>
<span id="loc-count" style="font-size:12px; font-weight:400; color:#6b7280; margin-left:6px;"></span>
</h3>
</div>
{{-- Add Location form --}}
<div style="padding:16px 20px; border-bottom:1px solid #f3f4f6;">
<form id="add-loc-form" method="POST" action="" style="display:flex; gap:8px; align-items:flex-start;">
@csrf
<div style="flex:1;">
<input type="text" name="name" id="add-loc-name"
placeholder="New location name…"
class="form-input" style="width:100%;">
</div>
<button type="submit" class="btn-primary" style="white-space:nowrap; flex-shrink:0;">Add Location</button>
</form>
</div>
{{-- Locations list --}}
<div id="loc-list" style="padding:8px 0;"></div>
</div>
</div>
</div>
{{-- Pre-load all project+location data as JSON for client-side rendering --}}
<script>
var projectsData = @json($projects->map(function($p) {
return [
'id' => $p->id,
'name' => $p->name,
'is_active' => $p->is_active,
'locations' => $p->locations->map(fn($l) => [
'id' => $l->id,
'name' => $l->name,
'is_active' => $l->is_active,
])->values(),
];
})->values());
// Route templates (server-rendered, safe)
var routeStoreLocation = '{{ url("settings/projects") }}/__ID__/locations';
var routeUpdateLocation = '{{ url("settings/projects") }}/__ID__/locations/__LOC__';
var routeDeleteLocation = '{{ url("settings/projects") }}/__ID__/locations/__LOC__';
var csrfToken = document.querySelector('meta[name="csrf-token"]').content;
var selectedProjectId = null;
function selectProject(id) {
// Highlight selected row
document.querySelectorAll('.proj-row').forEach(function(r) {
r.classList.remove('selected');
});
var row = document.getElementById('proj-row-' + id);
if (row) row.classList.add('selected');
selectedProjectId = id;
var proj = projectsData.find(function(p) { return p.id === id; });
if (!proj) return;
// Show content panel
document.getElementById('loc-placeholder').style.display = 'none';
document.getElementById('loc-content').style.display = '';
// Update header
document.getElementById('loc-project-name').textContent = proj.name;
document.getElementById('loc-count').textContent = proj.locations.length;
// Set add-form action
document.getElementById('add-loc-form').action = routeStoreLocation.replace('__ID__', id);
// Render locations
renderLocations(proj);
}
function renderLocations(proj) {
var list = document.getElementById('loc-list');
var locs = proj.locations;
if (locs.length === 0) {
list.innerHTML = '<div style="padding:28px 20px; text-align:center; color:#9ca3af; font-size:13.5px;">No locations yet. Add one above.</div>';
return;
}
var html = '';
locs.forEach(function(loc) {
var updateUrl = routeUpdateLocation.replace('__ID__', proj.id).replace('__LOC__', loc.id);
var deleteUrl = routeDeleteLocation.replace('__ID__', proj.id).replace('__LOC__', loc.id);
var activeChecked = loc.is_active ? 'checked' : '';
var badgeHtml = loc.is_active ? '' : '<span class="badge-inactive" style="margin-left:6px;">Inactive</span>';
html += '<div class="loc-row" id="loc-row-' + loc.id + '">';
// Display row
html += '<div class="loc-display-' + loc.id + '" style="display:flex; align-items:center; justify-content:space-between; padding:9px 16px;">';
html += '<div style="display:flex; align-items:center; gap:8px; flex:1; min-width:0;">';
html += '<svg width="13" height="13" fill="none" stroke="' + (loc.is_active ? '#22c55e' : '#9ca3af') + '" viewBox="0 0 24 24" style="flex-shrink:0;">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>';
html += '</svg>';
html += '<span style="font-size:13px; color:#374151;">' + escHtml(loc.name) + '</span>';
html += badgeHtml;
html += '</div>';
html += '<div style="display:flex; gap:4px; flex-shrink:0;">';
html += '<button type="button" onclick="openEditLoc(' + loc.id + ', \'' + escJs(loc.name) + '\', ' + (loc.is_active ? 'true' : 'false') + ')" class="btn-secondary btn-sm" style="padding:3px 8px; font-size:12px;">Edit</button>';
html += '<form method="POST" action="' + deleteUrl + '" id="del-loc-' + loc.id + '">';
html += '<input type="hidden" name="_token" value="' + csrfToken + '">';
html += '<input type="hidden" name="_method" value="DELETE">';
html += '<button type="button" onclick="confirmDelete(document.getElementById(\'del-loc-' + loc.id + '\'), \'Delete Location\', \'Delete &quot;' + escJs(loc.name) + '&quot;?\')" class="btn-danger btn-sm" style="padding:3px 8px; font-size:12px;">Delete</button>';
html += '</form>';
html += '</div>';
html += '</div>';
// Edit form row
html += '<div class="edit-form loc-edit-' + loc.id + '" style="padding:10px 16px; background:#f8fafc; border-top:1px solid #e5e7eb; gap:8px; align-items:center;">';
html += '<form method="POST" action="' + updateUrl + '" style="display:flex; gap:8px; align-items:center; flex:1;">';
html += '<input type="hidden" name="_token" value="' + csrfToken + '">';
html += '<input type="hidden" name="_method" value="PATCH">';
html += '<input type="text" name="name" id="edit-loc-name-' + loc.id + '" value="' + escHtml(loc.name) + '" class="form-input" style="flex:1; font-size:13px;">';
html += '<label style="display:flex; align-items:center; gap:4px; font-size:12px; color:#374151; white-space:nowrap; cursor:pointer;">';
html += '<input type="hidden" name="is_active" value="0">';
html += '<input type="checkbox" name="is_active" value="1" id="edit-loc-active-' + loc.id + '" ' + activeChecked + ' style="width:14px; height:14px; cursor:pointer;">';
html += ' Active';
html += '</label>';
html += '<button type="submit" class="btn-primary" style="padding:5px 12px; font-size:12px; white-space:nowrap;">Save</button>';
html += '<button type="button" onclick="closeEditLoc(' + loc.id + ')" class="btn-secondary btn-sm" style="padding:5px 10px; font-size:12px;">Cancel</button>';
html += '</form>';
html += '</div>';
html += '</div>';
html += '<div style="height:1px; background:#f3f4f6; margin:0 16px;"></div>';
});
list.innerHTML = html;
document.getElementById('loc-count').textContent = locs.length;
}
// ── Project edit helpers ──
function openEditProject(id, name, isActive) {
// Hide display row, show edit form
document.querySelector('.proj-display-' + id).style.display = 'none';
var editEl = document.querySelector('.proj-edit-' + id);
editEl.classList.add('open');
document.getElementById('edit-proj-name-' + id).value = name;
document.getElementById('edit-proj-active-' + id).checked = isActive;
}
function closeEditProject(id) {
document.querySelector('.proj-display-' + id).style.display = '';
document.querySelector('.proj-edit-' + id).classList.remove('open');
}
// ── Location edit helpers ──
function openEditLoc(id, name, isActive) {
document.querySelector('.loc-display-' + id).style.display = 'none';
var editEl = document.querySelector('.loc-edit-' + id);
editEl.classList.add('open');
document.getElementById('edit-loc-name-' + id).value = name;
document.getElementById('edit-loc-active-' + id).checked = isActive;
}
function closeEditLoc(id) {
document.querySelector('.loc-display-' + id).style.display = '';
document.querySelector('.loc-edit-' + id).classList.remove('open');
}
// ── Utilities ──
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escJs(s) {
return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'");
}
// Auto-select project after redirect (restore selection from URL hash or first project)
(function() {
var hash = window.location.hash;
var autoId = null;
if (hash && hash.startsWith('#project-')) {
autoId = parseInt(hash.replace('#project-', ''), 10);
} else if (projectsData.length > 0) {
autoId = projectsData[0].id;
}
if (autoId) {
// Wait for DOM
setTimeout(function() { selectProject(autoId); }, 0);
}
})();
// When project form submitted, attach hash to redirect
document.querySelectorAll('.proj-row').forEach(function(row) {
var id = parseInt(row.getAttribute('data-id'), 10);
// Update add-location form to set hash on submit for restoring selection
});
</script>
@endsection

View File

@ -27,6 +27,8 @@ use App\Http\Controllers\Sales\PaymentReceiptController;
use App\Http\Controllers\Sales\SalesInvoiceController; use App\Http\Controllers\Sales\SalesInvoiceController;
use App\Http\Controllers\Sales\SalesOrderController; use App\Http\Controllers\Sales\SalesOrderController;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\Settings\ProjectSettingController;
use App\Models\Settings\Location;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
@ -120,6 +122,15 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('settings/integrations', [SettingsController::class, 'integrations'])->name('settings.integrations'); 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/whatsapp', [SettingsController::class, 'updateWhatsapp'])->name('settings.integrations.whatsapp');
Route::post('settings/integrations/test-whatsapp', [SettingsController::class, 'testWhatsappConnection'])->name('settings.integrations.test-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');
}); });
}); });