feat: supplier modal wizard, pipeline delete, sidebar cleanup

- Replace two-tab supplier selector with two-step wizard (method select → suppliers → summary)
- Add per-item channel picker (Email / WhatsApp / Both) in By Item mode
- Add confirmation summary step before submitting By Item supplier assignments
- Add type-to-confirm delete on pipeline list rows
- Redirect purchase.requests.index to pipeline (same data, single entry point)
- Remove Purchase Requests from sidebar nav
- Add edit-request-modal, supplier-invite-list components
- Add address coordinates migration for settings_locations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ghassan Yusuf 2026-05-25 17:08:58 +03:00
parent dd924904c5
commit d8cab94bcb
18 changed files with 1943 additions and 840 deletions

View File

@ -369,3 +369,30 @@ The create form lives in `resources/views/components/purchase/request-modal.blad
- **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.
### 11. Data entry pages — AJAX only, no page refreshes
All settings and management pages where users create, edit, or delete records MUST use `fetch()` AJAX. No `<form>` submissions, no page reloads, no redirects after data entry.
**Controller:** return `response()->json(...)` for all create/update/delete endpoints. Laravel auto-returns 422 JSON on validation failure when `Accept: application/json` is set.
**Frontend fetch helper pattern:**
```javascript
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function api(url, method, data) {
var opts = { method: method, headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' } };
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
});
}
```
**After each operation:**
- On success: update the DOM in-place (append row, update text, remove element), then call `showToast('Done.', 'success')`
- On error: call `showToast(err.message || 'Error', 'error')`
- For deletes: use `confirmAction(title, body, onConfirm)` (not `confirm()`) before calling the API
**Never** use `<form method="POST">` for inline data entry on settings/management pages. `<form>` submissions that navigate away from the page are banned for these flows.

View File

@ -12,9 +12,7 @@ class PurchaseRequestController extends Controller
{
public function index()
{
$requests = PurchaseRequest::with(['requestedBy', 'items'])->latest()->paginate(15);
return view('purchase.requests.index', compact('requests'));
return redirect()->route('purchase.pipeline.index');
}
public function create()

View File

@ -11,53 +11,106 @@ class ProjectSettingController extends Controller
{
public function index()
{
$projects = ProjectSetting::with('locations')->orderBy('name')->get();
return view('settings.projects.index', compact('projects'));
$projects = ProjectSetting::with(['locations' => function ($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()),
];
return view('settings.projects.index', compact('projects', 'stats'));
}
public function store(Request $request)
{
$request->validate(['name' => 'required|string|max:255|unique:settings_projects,name']);
ProjectSetting::create(['name' => $request->name, 'is_active' => true]);
return redirect()->route('settings.projects.index')->with('success', 'Project added.');
$validated = $request->validate(['name' => 'required|string|max:255|unique:settings_projects,name']);
$project = ProjectSetting::create(['name' => $validated['name'], 'is_active' => true]);
return response()->json(['project' => [
'id' => $project->id,
'name' => $project->name,
'is_active' => $project->is_active,
]]);
}
public function update(Request $request, ProjectSetting $project)
{
$request->validate(['name' => 'required|string|max:255|unique:settings_projects,name,' . $project->id]);
$validated = $request->validate([
'name' => 'required|string|max:255|unique:settings_projects,name,' . $project->id,
]);
$project->update([
'name' => $request->name,
'name' => $validated['name'],
'is_active' => $request->boolean('is_active', true),
]);
return redirect()->route('settings.projects.index')->with('success', 'Project updated.');
return response()->json(['project' => [
'id' => $project->id,
'name' => $project->name,
'is_active' => $project->is_active,
]]);
}
public function destroy(ProjectSetting $project)
{
$project->delete();
return redirect()->route('settings.projects.index')->with('success', 'Project deleted.');
return response()->json(['ok' => true]);
}
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.');
$validated = $request->validate([
'name' => 'required|string|max:255',
'address' => 'nullable|string|max:500',
'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180',
]);
$location = $project->locations()->create([
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'latitude' => $validated['latitude'] ?? null,
'longitude' => $validated['longitude'] ?? null,
'is_active' => true,
]);
return response()->json(['location' => [
'id' => $location->id,
'name' => $location->name,
'address' => $location->address,
'latitude' => $location->latitude,
'longitude' => $location->longitude,
'is_active' => $location->is_active,
]]);
}
public function updateLocation(Request $request, ProjectSetting $project, Location $location)
{
$request->validate(['name' => 'required|string|max:255']);
$validated = $request->validate([
'name' => 'required|string|max:255',
'address' => 'nullable|string|max:500',
'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180',
]);
$location->update([
'name' => $request->name,
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'latitude' => $validated['latitude'] ?? null,
'longitude' => $validated['longitude'] ?? null,
'is_active' => $request->boolean('is_active', true),
]);
return redirect()->route('settings.projects.index')->with('success', 'Location updated.');
return response()->json(['location' => [
'id' => $location->id,
'name' => $location->name,
'address' => $location->address,
'latitude' => $location->latitude,
'longitude' => $location->longitude,
'is_active' => $location->is_active,
]]);
}
public function destroyLocation(ProjectSetting $project, Location $location)
{
$location->delete();
return redirect()->route('settings.projects.index')->with('success', 'Location deleted.');
return response()->json(['ok' => true]);
}
}

View File

@ -8,9 +8,9 @@ class Location extends Model
{
protected $table = 'settings_locations';
protected $fillable = ['name', 'project_id', 'is_active'];
protected $fillable = ['name', 'project_id', 'is_active', 'address', 'latitude', 'longitude'];
protected $casts = ['is_active' => 'boolean'];
protected $casts = ['is_active' => 'boolean', 'latitude' => 'float', 'longitude' => 'float'];
public function scopeActive($query)
{

View File

@ -0,0 +1,24 @@
<?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->string('address', 500)->nullable()->after('name');
$table->decimal('latitude', 10, 7)->nullable()->after('address');
$table->decimal('longitude', 11, 7)->nullable()->after('latitude');
});
}
public function down(): void
{
Schema::table('settings_locations', function (Blueprint $table) {
$table->dropColumn(['address', 'latitude', 'longitude']);
});
}
};

View File

@ -44,4 +44,17 @@
</x-primary-button>
</div>
</form>
{{-- Dev credentials hint --}}
@if(app()->isLocal())
<div style="margin-top:1.5rem; padding:0.75rem 1rem; background:#f0f9ff; border:1px solid #bae6fd; border-radius:0.5rem; font-size:0.8rem; color:#0369a1;">
<div style="font-weight:600; margin-bottom:0.25rem;">Dev Credentials</div>
<div>Email: <span style="font-family:monospace; font-weight:600;">admin@erp.com</span></div>
<div>Password: <span style="font-family:monospace; font-weight:600;">password</span></div>
<button type="button" onclick="document.getElementById('email').value='admin@erp.com'; document.getElementById('password').value='password';"
style="margin-top:0.5rem; padding:0.25rem 0.75rem; background:#0ea5e9; color:#fff; border:none; border-radius:0.375rem; cursor:pointer; font-size:0.75rem;">
Fill in
</button>
</div>
@endif
</x-guest-layout>

View File

@ -0,0 +1,346 @@
@props(['purchaseRequest'])
@php
$prId = $purchaseRequest->id;
$prefix = 'mprEdit' . $prId;
// Re-open on validation error only when THIS request was being edited
$repop = $errors->any() && (string) old('_edit_request_id') === (string) $prId;
// Load items if not already eager-loaded
$prItems = $purchaseRequest->relationLoaded('items') ? $purchaseRequest->items : $purchaseRequest->load('items')->items;
$existingItems = $repop && old('items')
? collect(old('items'))->map(fn($i) => (object)$i)
: $prItems;
// Current field values (old() values take priority when repopulating)
$curDate = $repop ? old('date', $purchaseRequest->date ? \Carbon\Carbon::parse($purchaseRequest->date)->format('Y-m-d') : '') : ($purchaseRequest->date ? \Carbon\Carbon::parse($purchaseRequest->date)->format('Y-m-d') : '');
$curProject = $repop ? old('project_name', $purchaseRequest->project_name ?? '') : ($purchaseRequest->project_name ?? '');
$curReqBy = $repop ? old('requested_by_name', $purchaseRequest->requested_by_name ?? '') : ($purchaseRequest->requested_by_name ?? '');
$curReqDate = $repop ? old('required_date_text', $purchaseRequest->required_date_text ?? '') : ($purchaseRequest->required_date_text ?? '');
$curLoc = $repop ? old('location', $purchaseRequest->location ?? '') : ($purchaseRequest->location ?? '');
$curDept = $repop ? old('department', $purchaseRequest->department ?? '') : ($purchaseRequest->department ?? '');
$curRemarks = $repop ? old('remarks', $purchaseRequest->remarks ?? '') : ($purchaseRequest->remarks ?? '');
// Projects & locations for cascading dropdowns
$editProjects = \App\Models\Settings\ProjectSetting::active()
->with(['locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); }])
->orderBy('name')
->get();
$editProjectsData = $editProjects->map(function ($p) {
return [
'id' => $p->id,
'name' => $p->name,
'locations' => $p->locations->map(fn($l) => ['name' => $l->name])->values()->toArray(),
];
})->values()->toArray();
$editProjectsJson = json_encode($editProjectsData);
// Check if current project is in the list
$curProjectInList = $editProjects->contains('name', $curProject);
@endphp
{{-- ── Trigger button ── --}}
<button type="button" onclick="{{ $prefix }}Open()" class="btn-secondary btn-sm">
Edit
</button>
{{-- ── Modal overlay ── --}}
<div id="{{ $prefix }}Overlay"
onclick="if(event.target===this){{ $prefix }}Close()"
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,#0f766e 0%,#0284c7 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</div>
<div>
<h2 style="color:white;font-size:1rem;font-weight:700;line-height:1.2;">Edit Purchase Request</h2>
<p style="color:#bae6fd;font-size:0.7rem;margin-top:0.1rem;">{{ $purchaseRequest->request_number }}</p>
</div>
</div>
<button onclick="{{ $prefix }}Close()" 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($repop)
<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.update', $purchaseRequest) }}" method="POST" id="{{ $prefix }}Form">
@csrf
@method('PUT')
<input type="hidden" name="_edit_request_id" value="{{ $prId }}">
{{-- 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:#0ea5e9;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="{{ $curDate }}" 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="{{ $prefix }}Project" required class="form-input"
onchange="{{ $prefix }}FilterLoc(this.value)">
<option value=""> Select Project </option>
@if($curProject && !$curProjectInList)
<option value="{{ $curProject }}" selected>{{ $curProject }}</option>
@endif
@foreach($editProjects as $proj)
<option value="{{ $proj->name }}" {{ $curProject === $proj->name ? 'selected' : '' }}>{{ $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="{{ $curReqBy }}" 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="{{ $curReqDate }}" class="form-input" placeholder="e.g. Urgent, or 2026-06-01">
</div>
<div>
<label class="form-label">Location / Site</label>
<select name="location" id="{{ $prefix }}Location" class="form-input">
<option value=""> Select Location </option>
</select>
</div>
<div>
<label class="form-label">Department</label>
<input type="text" name="department" value="{{ $curDept }}" class="form-input" placeholder="e.g. Operations">
</div>
</div>
</div>
{{-- 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:#0ea5e9;border-radius:2px;"></span>
Material Details
</h3>
<button type="button" id="{{ $prefix }}AddRow" 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="{{ $prefix }}Items">
@foreach($existingItems as $idx => $item)
@php
$iArr = is_array($item) ? $item : ($item instanceof \Illuminate\Database\Eloquent\Model ? $item->toArray() : (array)$item);
$iDate = !empty($iArr['required_date']) ? \Carbon\Carbon::parse($iArr['required_date'])->format('Y-m-d') : '';
@endphp
<tr class="{{ $prefix }}-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="{{ $prefix }}-row-num">{{ $idx + 1 }}</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="text" name="items[{{ $idx }}][description]" value="{{ $iArr['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="{{ $iArr['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="{{ $iArr['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="{{ $iArr['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="{{ $iDate }}"
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="{{ $prefix }}-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>
{{-- 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:#0ea5e9;border-radius:2px;"></span>
Remarks / Notes
</h3>
<textarea name="remarks" rows="2" class="form-textarea">{{ $curRemarks }}</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="{{ $prefix }}Form" class="btn-primary">Save Changes</button>
<button type="button" onclick="{{ $prefix }}Close()" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<script>
(function () {
function {{ $prefix }}Open() {
var el = document.getElementById('{{ $prefix }}Overlay');
el.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function {{ $prefix }}Close() {
var el = document.getElementById('{{ $prefix }}Overlay');
el.style.display = 'none';
document.body.style.overflow = '';
}
window.{{ $prefix }}Open = {{ $prefix }}Open;
window.{{ $prefix }}Close = {{ $prefix }}Close;
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && document.getElementById('{{ $prefix }}Overlay').style.display === 'flex') {
{{ $prefix }}Close();
}
});
// Auto-open on validation error for this request
@if($repop)
document.addEventListener('DOMContentLoaded', function () { {{ $prefix }}Open(); });
@endif
// ── Dynamic rows ──────────────────────────────────────────────────────────
var rowIdx = {{ $existingItems->count() }};
function renumber() {
document.querySelectorAll('#{{ $prefix }}Items .{{ $prefix }}-row-num').forEach(function (el, i) {
el.textContent = i + 1;
});
}
function newRow() {
var idx = rowIdx++;
var tr = document.createElement('tr');
tr.className = '{{ $prefix }}-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="{{ $prefix }}-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="{{ $prefix }}-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('{{ $prefix }}AddRow');
var tbody = document.getElementById('{{ $prefix }}Items');
if (!addBtn || !tbody) return;
addBtn.addEventListener('click', function () {
tbody.appendChild(newRow());
renumber();
});
tbody.addEventListener('click', function (e) {
if (e.target.classList.contains('{{ $prefix }}-remove-row')) {
if (tbody.querySelectorAll('.{{ $prefix }}-item-row').length > 1) {
e.target.closest('tr').remove();
renumber();
}
}
});
renumber();
});
// ── Cascading project → location dropdown ─────────────────────────────────
var _editProjects{{ $prId }} = {!! $editProjectsJson !!};
var _curLoc{{ $prId }} = {!! json_encode($curLoc) !!};
function {{ $prefix }}FilterLoc(projectName) {
var sel = document.getElementById('{{ $prefix }}Location');
sel.innerHTML = '<option value="">— Select Location —</option>';
if (!projectName) { sel.disabled = true; return; }
var proj = _editProjects{{ $prId }}.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;
sel.appendChild(opt);
});
sel.disabled = false;
} else {
sel.disabled = true;
}
}
window.{{ $prefix }}FilterLoc = {{ $prefix }}FilterLoc;
// On load: populate locations and pre-select the current ones
document.addEventListener('DOMContentLoaded', function () {
var curProj = document.getElementById('{{ $prefix }}Project').value;
if (curProj) {
{{ $prefix }}FilterLoc(curProj);
var locSel = document.getElementById('{{ $prefix }}Location');
var matched = false;
for (var i = 0; i < locSel.options.length; i++) {
if (locSel.options[i].value === _curLoc{{ $prId }}) {
locSel.selectedIndex = i;
matched = true;
break;
}
}
// If saved location not in project's list, add it as a selectable option
if (!matched && _curLoc{{ $prId }}) {
var opt = document.createElement('option');
opt.value = _curLoc{{ $prId }};
opt.textContent = _curLoc{{ $prId }};
opt.selected = true;
locSel.appendChild(opt);
locSel.disabled = false;
}
} else {
document.getElementById('{{ $prefix }}Location').disabled = true;
}
});
})();
</script>

View File

@ -1,6 +1,18 @@
@php
$hasErrors = $errors->any();
$mprProjects = \App\Models\Settings\ProjectSetting::active()->with(['locations' => function($q){ $q->where('is_active', true)->orderBy('name'); }])->orderBy('name')->get();
$hasErrors = $errors->any();
$mprProjects = \App\Models\Settings\ProjectSetting::active()
->with(['locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); }])
->orderBy('name')
->get();
$mprProjectsJson = $mprProjects->map(function ($p) {
return [
'id' => $p->id,
'name' => $p->name,
'locations' => $p->locations->map(function ($l) {
return ['name' => $l->name];
})->values(),
];
})->values();
@endphp
{{-- Trigger button --}}
@ -242,7 +254,7 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
})();
// 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 mprProjectsData = @json($mprProjectsJson);
var mprOldLocation = "{{ old('location') }}";
var mprOldProjectName = "{{ old('project_name') }}";

View File

@ -0,0 +1,93 @@
@props([
'suppliers',
'alreadyIds' => [],
'formAction',
])
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">
{{ count($alreadyIds) ? 'Add More Suppliers' : 'Select Suppliers' }}
</div>
<input type="text" id="supplier-search" placeholder="Search suppliers…" oninput="filterSuppliers(this.value)"
style="width:100%;padding:9px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;margin-bottom:12px;box-sizing:border-box;">
<form method="POST" action="{{ $formAction }}" id="rfq-form">
@csrf
<div id="supplier-list" style="display:flex;flex-direction:column;gap:6px;max-height:400px;overflow-y:auto;padding-right:2px;">
@forelse($suppliers as $supplier)
@php $already = in_array($supplier->id, $alreadyIds); @endphp
<div class="sup-row" data-name="{{ strtolower($supplier->name) }}"
style="border:1.5px solid #e2e8f0;border-radius:10px;padding:12px 14px;transition:border-color .15s;{{ $already ? 'opacity:.45;' : '' }}">
<label style="display:flex;align-items:center;gap:12px;cursor:{{ $already ? 'default' : 'pointer' }};">
<input type="checkbox" name="supplier_ids[]" value="{{ $supplier->id }}"
data-sid="{{ $supplier->id }}" onchange="toggleRow(this)"
{{ $already ? 'disabled' : '' }}
style="width:17px;height:17px;cursor:pointer;flex-shrink:0;">
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:#0f172a;">{{ $supplier->name }}</div>
<div style="font-size:11px;color:#64748b;margin-top:1px;">
{{ $supplier->email ?: '—' }} · {{ $supplier->phone ?: '—' }}
</div>
</div>
@if($already)
<span style="font-size:11px;font-weight:600;color:#64748b;background:#f1f5f9;padding:3px 10px;border-radius:20px;">Already invited</span>
@endif
</label>
<div id="ch-{{ $supplier->id }}" style="display:none;margin-top:10px;padding-top:10px;border-top:1px dashed #e2e8f0;">
<div style="font-size:11px;font-weight:700;color:#64748b;margin-bottom:8px;">Send via:</div>
<div style="display:flex;gap:8px;">
@foreach(['whatsapp' => 'WhatsApp', 'email' => 'Email', 'both' => 'Both'] as $val => $lbl)
<label style="flex:1;cursor:pointer;">
<input type="radio" name="channel_{{ $supplier->id }}" value="{{ $val }}" {{ $val === 'both' ? 'checked' : '' }}
style="display:none;" id="ch-{{ $supplier->id }}-{{ $val }}"
onchange="styleChannelBtns({{ $supplier->id }})">
<span id="chbtn-{{ $supplier->id }}-{{ $val }}"
onclick="document.getElementById('ch-{{ $supplier->id }}-{{ $val }}').checked=true;styleChannelBtns({{ $supplier->id }});"
style="display:block;text-align:center;padding:7px 6px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;
border:1.5px solid {{ $val === 'both' ? '#0ea5e9' : '#e2e8f0' }};
background:{{ $val === 'both' ? '#eff6ff' : '#fff' }};
color:{{ $val === 'both' ? '#0284c7' : '#64748b' }};">
{{ $lbl }}
</span>
</label>
@endforeach
</div>
</div>
</div>
@empty
<p style="text-align:center;color:#94a3b8;padding:24px;">No active suppliers found. Add suppliers first.</p>
@endforelse
</div>
<button type="submit"
style="width:100%;margin-top:20px;padding:13px;background:linear-gradient(135deg,#0ea5e9,#0284c7);color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;cursor:pointer;">
Send Invitations
</button>
</form>
<script>
function filterSuppliers(q) {
document.querySelectorAll('.sup-row').forEach(function(row) {
row.style.display = row.dataset.name.includes(q.toLowerCase()) ? '' : 'none';
});
}
function toggleRow(cb) {
var ch = document.getElementById('ch-' + cb.dataset.sid);
if (ch) ch.style.display = cb.checked ? 'block' : 'none';
}
function styleChannelBtns(sid) {
['whatsapp','email','both'].forEach(function(v) {
var inp = document.getElementById('ch-' + sid + '-' + v);
var btn = document.getElementById('chbtn-' + sid + '-' + v);
if (!inp || !btn) return;
if (inp.checked) {
btn.style.borderColor = '#0ea5e9'; btn.style.background = '#eff6ff'; btn.style.color = '#0284c7';
} else {
btn.style.borderColor = '#e2e8f0'; btn.style.background = '#fff'; btn.style.color = '#64748b';
}
});
}
</script>

View File

@ -0,0 +1,588 @@
@props([
'pr',
'suppliers',
'selectedIds' => [],
])
{{-- ============================================================
SELECT SUPPLIERS MODAL
============================================================ --}}
<div id="supplier-modal" class="pipe-modal" role="dialog" aria-modal="true" aria-labelledby="sup-modal-title">
<div style="background:#fff;border-radius:20px;width:100%;max-width:680px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 30px 60px rgba(0,0,0,.3);overflow:hidden;">
{{-- Header --}}
<div style="padding:20px 24px 16px;border-bottom:1px solid #f1f5f9;flex-shrink:0;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:4px;">
<div>
<div id="sup-modal-title" style="font-size:17px;font-weight:700;color:#0f172a;">Request for Quotation</div>
<div id="sup-modal-subtitle" style="font-size:12px;color:#64748b;margin-top:3px;">How do you want to assign suppliers?</div>
</div>
<button onclick="closeSupplierModal()" aria-label="Close"
style="width:32px;height:32px;border-radius:8px;border:none;background:#f1f5f9;cursor:pointer;font-size:18px;color:#64748b;display:flex;align-items:center;justify-content:center;flex-shrink:0;">×</button>
</div>
{{-- Mode badge row: hidden in Step 1, shown in Step 2 --}}
<div id="sup-mode-badge-row" style="display:none;align-items:center;gap:8px;margin-top:10px;">
<button type="button" onclick="goBack()"
style="display:flex;align-items:center;gap:4px;font-size:12px;color:#2563eb;background:none;border:none;cursor:pointer;padding:0;font-weight:600;">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 18l-6-6 6-6"/></svg>
Change method
</button>
<span style="color:#cbd5e1;">·</span>
<span id="sup-mode-badge" style="font-size:11px;padding:2px 9px;border-radius:10px;font-weight:700;"></span>
</div>
</div>
{{-- Step 1: Method selection --}}
<div id="sup-step1" style="flex:1;display:flex;flex-direction:column;">
<div style="padding:24px;display:flex;flex-direction:column;gap:12px;flex:1;">
<button type="button" onclick="showStep('global')"
style="width:100%;text-align:left;border:2px solid #e2e8f0;border-radius:12px;padding:18px 20px;cursor:pointer;display:flex;align-items:center;gap:16px;background:#fff;transition:border-color .15s,background .15s;"
onmouseover="this.style.borderColor='#2563eb';this.style.background='#f8fbff'"
onmouseout="this.style.borderColor='#e2e8f0';this.style.background='#fff'">
<div style="width:44px;height:44px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">📦</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:700;color:#0f172a;">Full Order</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">One set of suppliers handles the entire purchase request</div>
</div>
<svg width="16" height="16" fill="none" stroke="#cbd5e1" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18l6-6-6-6"/></svg>
</button>
<button type="button" onclick="showStep('item')"
style="width:100%;text-align:left;border:2px solid #e2e8f0;border-radius:12px;padding:18px 20px;cursor:pointer;display:flex;align-items:center;gap:16px;background:#fff;transition:border-color .15s,background .15s;"
onmouseover="this.style.borderColor='#2563eb';this.style.background='#f8fbff'"
onmouseout="this.style.borderColor='#e2e8f0';this.style.background='#fff'">
<div style="width:44px;height:44px;background:#f0fdf4;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">🔀</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:700;color:#0f172a;">By Item</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">Assign different suppliers to specific items in this request</div>
</div>
<svg width="16" height="16" fill="none" stroke="#cbd5e1" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18l6-6-6-6"/></svg>
</button>
</div>
<div style="padding:14px 24px;border-top:1px solid #f1f5f9;display:flex;justify-content:flex-end;flex-shrink:0;background:#fafafa;">
<button type="button" onclick="closeSupplierModal()"
style="padding:8px 18px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;font-weight:600;color:#64748b;background:#fff;cursor:pointer;">
Cancel
</button>
</div>
</div>
{{-- Step 2: Supplier selection --}}
<div id="sup-step2" style="flex:1;display:none;flex-direction:column;min-height:0;">
{{-- Single form wrapping both panes --}}
<form id="sup-form" action="{{ route('purchase.requests.rfq.select', $pr) }}" method="POST"
style="flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
@csrf
<input type="hidden" name="mode" id="sup-mode" value="">
{{-- ── GLOBAL PANE ── --}}
<div id="sup-global-pane" style="flex:1;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;">
{{-- Search --}}
<div style="padding:14px 24px 10px;flex-shrink:0;">
<div style="position:relative;">
<svg style="position:absolute;left:11px;top:50%;transform:translateY(-50%);pointer-events:none;"
width="14" height="14" fill="none" stroke="#94a3b8" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35"/>
</svg>
<input id="sup-search" type="text" placeholder="Search suppliers…" oninput="filterGlobalSups(this.value)"
style="width:100%;box-sizing:border-box;padding:9px 12px 9px 34px;border:1px solid #e2e8f0;border-radius:9px;font-size:13px;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
</div>
</div>
<div id="sup-list" style="flex:1;">
@forelse($suppliers as $sup)
@php $alreadyAdded = in_array($sup->id, $selectedIds); @endphp
<div class="g-sup-item" data-name="{{ strtolower($sup->name) }}"
style="padding:11px 24px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f8fafc;transition:background .1s;
{{ $alreadyAdded ? 'opacity:.45;pointer-events:none;' : '' }}">
<input type="checkbox" name="supplier_ids[]" value="{{ $sup->id }}" id="gsup-{{ $sup->id }}"
{{ $alreadyAdded ? 'disabled checked' : '' }}
onchange="toggleGlobalChan({{ $sup->id }}, this.checked); updateGlobalCount();"
style="width:17px;height:17px;accent-color:#2563eb;cursor:pointer;flex-shrink:0;">
<label for="gsup-{{ $sup->id }}" style="flex:1;cursor:{{ $alreadyAdded ? 'default' : 'pointer' }};">
<div style="font-size:13px;font-weight:600;color:#0f172a;">
{{ $sup->name }}
@if($alreadyAdded)
<span style="font-size:10px;background:#dcfce7;color:#15803d;padding:1px 6px;border-radius:10px;margin-left:6px;font-weight:700;">Added</span>
@endif
</div>
@if($sup->email || $sup->phone)
<div style="font-size:11px;color:#94a3b8;margin-top:1px;">
{{ collect([$sup->email, $sup->phone])->filter()->implode(' · ') }}
</div>
@endif
</label>
@if(!$alreadyAdded)
<div id="gchan-{{ $sup->id }}" style="display:none;flex-shrink:0;">
<input type="hidden" name="channel_{{ $sup->id }}" id="gchan-val-{{ $sup->id }}" value="email">
<div style="display:flex;border:1px solid #e2e8f0;border-radius:7px;overflow:hidden;">
<span onclick="setGChan({{ $sup->id }},'email')" id="gce-{{ $sup->id }}"
style="padding:5px 9px;font-size:10px;font-weight:700;cursor:pointer;background:#eff6ff;color:#2563eb;">Email</span>
<span onclick="setGChan({{ $sup->id }},'whatsapp')" id="gcw-{{ $sup->id }}"
style="padding:5px 9px;font-size:10px;font-weight:700;cursor:pointer;background:#fff;color:#94a3b8;border-left:1px solid #e2e8f0;">WA</span>
<span onclick="setGChan({{ $sup->id }},'both')" id="gcb-{{ $sup->id }}"
style="padding:5px 9px;font-size:10px;font-weight:700;cursor:pointer;background:#fff;color:#94a3b8;border-left:1px solid #e2e8f0;">Both</span>
</div>
</div>
@endif
</div>
@empty
<div style="padding:40px;text-align:center;color:#94a3b8;font-size:13px;">No active suppliers found.</div>
@endforelse
<div id="no-sup-msg" style="display:none;padding:30px;text-align:center;color:#94a3b8;font-size:13px;">No suppliers match your search.</div>
</div>
</div>
{{-- ── BY ITEM PANE ── --}}
<div id="sup-item-pane" style="flex:1;overflow-y:auto;overscroll-behavior:contain;display:none;flex-direction:column;">
@if($pr->items->isEmpty())
<div style="padding:40px;text-align:center;color:#94a3b8;font-size:13px;">This request has no items yet.</div>
@else
{{-- Column headers --}}
<div style="display:grid;grid-template-columns:1fr 175px 120px;gap:12px;padding:9px 24px;background:#f8fafc;border-bottom:1px solid #e2e8f0;flex-shrink:0;">
<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;">Item</div>
<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;">Assign Suppliers</div>
<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;">Channel</div>
</div>
{{-- Item rows --}}
@foreach($pr->items as $item)
<div style="display:grid;grid-template-columns:1fr 175px 120px;gap:12px;align-items:center;padding:13px 24px;border-bottom:1px solid #f8fafc;">
<div>
<div style="font-size:13px;font-weight:600;color:#0f172a;line-height:1.3;">{{ $item->description }}</div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">
Qty: {{ rtrim(rtrim(number_format($item->quantity_required,2),'0'),'.') }}{{ $item->unit ? ' '.$item->unit : '' }}
</div>
</div>
<div>
<button type="button" id="idd-btn-{{ $item->id }}"
onclick="toggleItemDd(event,{{ $item->id }})"
data-itemname="{{ $item->description }}"
data-itemqty="{{ rtrim(rtrim(number_format($item->quantity_required,2),'0'),'.') }}{{ $item->unit ? ' '.$item->unit : '' }}"
style="width:100%;padding:8px 11px;border:1.5px solid #e2e8f0;border-radius:8px;background:#fff;cursor:pointer;
display:flex;align-items:center;justify-content:space-between;gap:6px;text-align:left;transition:border .15s;">
<span id="idd-label-{{ $item->id }}" style="font-size:12px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
Select suppliers…
</span>
<svg width="11" height="11" fill="none" stroke="#94a3b8" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
</div>
<div id="ichan-item-btn-{{ $item->id }}"
style="display:flex;border:1.5px solid #e2e8f0;border-radius:8px;overflow:hidden;opacity:.35;pointer-events:none;">
<span onclick="setItemChan({{ $item->id }},'email')" id="ichan-ie-{{ $item->id }}"
style="flex:1;padding:7px 4px;font-size:10px;font-weight:700;cursor:pointer;text-align:center;background:#eff6ff;color:#2563eb;">Email</span>
<span onclick="setItemChan({{ $item->id }},'whatsapp')" id="ichan-iw-{{ $item->id }}"
style="flex:1;padding:7px 4px;font-size:10px;font-weight:700;cursor:pointer;text-align:center;background:#fff;color:#94a3b8;border-left:1.5px solid #e2e8f0;">WA</span>
<span onclick="setItemChan({{ $item->id }},'both')" id="ichan-ib-{{ $item->id }}"
style="flex:1;padding:7px 4px;font-size:10px;font-weight:700;cursor:pointer;text-align:center;background:#fff;color:#94a3b8;border-left:1.5px solid #e2e8f0;">Both</span>
</div>
</div>
@endforeach
{{-- Hidden channel inputs read by backend as channel_{sup_id} --}}
<div style="display:none;">
@foreach($suppliers as $sup)
<input type="hidden" name="channel_{{ $sup->id }}" id="ichan-val-{{ $sup->id }}" value="email">
@endforeach
</div>
@endif
</div>
{{-- Dropdown panels: fixed-positioned, one per item must be inside form so checkboxes are submitted --}}
@foreach($pr->items as $item)
<div id="idd-{{ $item->id }}"
style="display:none;position:fixed;z-index:99999;background:#fff;border:1.5px solid #e2e8f0;
border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.18);overflow:hidden;min-width:240px;">
<div style="padding:10px 10px 6px;">
<input type="text" placeholder="Search suppliers…"
oninput="filterItemDd({{ $item->id }}, this.value)"
style="width:100%;box-sizing:border-box;padding:7px 10px;border:1px solid #e2e8f0;border-radius:7px;font-size:12px;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
</div>
<div style="max-height:210px;overflow-y:auto;padding-bottom:4px;">
@forelse($suppliers as $sup)
@php $alreadyAdded = in_array($sup->id, $selectedIds); @endphp
<label class="idd-row-{{ $item->id }}" data-name="{{ strtolower($sup->name) }}"
style="display:flex;align-items:center;gap:10px;padding:8px 12px;cursor:{{ $alreadyAdded ? 'default' : 'pointer' }};
{{ $alreadyAdded ? 'opacity:.45;' : '' }}"
onmouseover="{{ $alreadyAdded ? '' : "this.style.background='#f8fafc'" }}"
onmouseout="this.style.background='transparent'">
<input type="checkbox" name="item_suppliers[{{ $item->id }}][]" value="{{ $sup->id }}"
data-supname="{{ $sup->name }}"
id="isup-{{ $item->id }}-{{ $sup->id }}"
{{ $alreadyAdded ? 'disabled checked' : '' }}
onchange="onItemSupChange({{ $item->id }}, {{ $sup->id }})"
style="width:15px;height:15px;accent-color:#2563eb;cursor:{{ $alreadyAdded ? 'default' : 'pointer' }};flex-shrink:0;">
<div style="min-width:0;">
<div style="font-size:12px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ $sup->name }}
@if($alreadyAdded)
<span style="font-size:9px;background:#dcfce7;color:#15803d;padding:1px 5px;border-radius:8px;margin-left:4px;">Added</span>
@endif
</div>
@if($sup->email)
<div style="font-size:10px;color:#94a3b8;">{{ $sup->email }}</div>
@endif
</div>
</label>
@empty
<div style="padding:16px;text-align:center;color:#94a3b8;font-size:12px;">No suppliers found.</div>
@endforelse
<div id="idd-no-{{ $item->id }}" style="display:none;padding:14px;text-align:center;color:#94a3b8;font-size:12px;">No results</div>
</div>
</div>
@endforeach
</form>
{{-- Footer --}}
<div style="padding:14px 24px;border-top:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;background:#fafafa;">
<div style="font-size:12px;color:#64748b;" id="sup-footer-msg">0 selected</div>
<div style="display:flex;gap:10px;">
<button type="button" onclick="closeSupplierModal()"
style="padding:8px 18px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;font-weight:600;color:#64748b;background:#fff;cursor:pointer;">
Cancel
</button>
<button type="button" onclick="submitSuppliers()"
style="padding:8px 22px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">
Save &amp; Continue
</button>
</div>
</div>
</div>{{-- /sup-step2 --}}
{{-- Step 3: Summary / confirmation (By Item only) --}}
<div id="sup-step3" style="flex:1;display:none;flex-direction:column;min-height:0;">
<div id="sup-summary-body" style="flex:1;overflow-y:auto;overscroll-behavior:contain;padding:20px 24px;">
</div>
<div style="padding:14px 24px;border-top:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;background:#fafafa;">
<button type="button" onclick="backToEdit()"
style="display:flex;align-items:center;gap:5px;font-size:13px;font-weight:600;color:#64748b;background:none;border:1px solid #e2e8f0;border-radius:8px;cursor:pointer;padding:8px 16px;">
Back to edit
</button>
<button type="button" onclick="document.getElementById('sup-form').submit()"
style="padding:8px 22px;background:#16a34a;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">
Confirm &amp; Send
</button>
</div>
</div>
</div>
</div>
<script>
// ---- Supplier modal ----
var _supTab = 'global';
function openSupplierModal() {
document.getElementById('supplier-modal').classList.add('open');
goBack();
}
function closeSupplierModal() {
closeAllItemDd();
document.getElementById('supplier-modal').classList.remove('open');
}
function showStep(method) {
_supTab = method;
document.getElementById('sup-mode').value = method === 'item' ? 'by_item' : 'global';
var badge = document.getElementById('sup-mode-badge');
if (method === 'global') {
badge.textContent = '📦 Full Order';
badge.style.background = '#eff6ff';
badge.style.color = '#2563eb';
} else {
badge.textContent = '🔀 By Item';
badge.style.background = '#f0fdf4';
badge.style.color = '#15803d';
}
document.getElementById('sup-global-pane').style.display = method === 'global' ? 'flex' : 'none';
document.getElementById('sup-item-pane').style.display = method === 'item' ? 'flex' : 'none';
document.getElementById('sup-modal-title').textContent = 'Select Suppliers';
document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request';
document.getElementById('sup-mode-badge-row').style.display = 'flex';
document.getElementById('sup-step1').style.display = 'none';
document.getElementById('sup-step2').style.display = 'flex';
if (method === 'global') {
setTimeout(function(){ document.getElementById('sup-search').focus(); }, 50);
}
updateFooter();
}
function goBack() {
document.querySelectorAll('#sup-form input[type="checkbox"]:not([disabled])').forEach(function(cb) {
cb.checked = false;
});
document.querySelectorAll('[id^="gchan-"]').forEach(function(el) { el.style.display = 'none'; });
var searchEl = document.getElementById('sup-search');
if (searchEl) { searchEl.value = ''; filterGlobalSups(''); }
document.querySelectorAll('[id^="idd-label-"]').forEach(function(el) {
el.textContent = 'Select suppliers…';
el.style.color = '#94a3b8';
});
document.querySelectorAll('[id^="ichan-item-btn-"]').forEach(function(el) {
el.style.opacity = '.35'; el.style.pointerEvents = 'none';
});
document.querySelectorAll('[id^="ichan-ie-"]').forEach(function(el) { el.style.background='#eff6ff'; el.style.color='#2563eb'; });
document.querySelectorAll('[id^="ichan-iw-"],[id^="ichan-ib-"]').forEach(function(el) { el.style.background='#fff'; el.style.color='#94a3b8'; });
_itemChan = {};
document.getElementById('sup-mode').value = '';
document.getElementById('sup-modal-title').textContent = 'Request for Quotation';
document.getElementById('sup-modal-subtitle').textContent = 'How do you want to assign suppliers?';
document.getElementById('sup-mode-badge-row').style.display = 'none';
document.getElementById('sup-step1').style.display = 'flex';
document.getElementById('sup-step2').style.display = 'none';
document.getElementById('sup-step3').style.display = 'none';
closeAllItemDd();
}
document.getElementById('supplier-modal').addEventListener('click', function(e) {
if (e.target === this) closeSupplierModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (typeof closeSignModal === 'function') closeSignModal();
closeSupplierModal();
}
});
// ── Global tab helpers ──
function filterGlobalSups(q) {
q = q.toLowerCase().trim();
var items = document.querySelectorAll('.g-sup-item');
var visible = 0;
items.forEach(function(item) {
var match = !q || item.dataset.name.indexOf(q) !== -1;
item.style.display = match ? 'flex' : 'none';
if (match) visible++;
});
document.getElementById('no-sup-msg').style.display = (visible === 0 && q) ? 'block' : 'none';
}
function toggleGlobalChan(id, checked) {
var el = document.getElementById('gchan-' + id);
if (el) el.style.display = checked ? 'block' : 'none';
updateFooter();
}
var chanStyles = {
email: { bg:'#eff6ff', fg:'#2563eb' },
whatsapp: { bg:'#f0fdf4', fg:'#15803d' },
both: { bg:'#fef3c7', fg:'#92400e' },
};
function applyBtnStyles(prefix, id, val) {
[['email','e'],['whatsapp','w'],['both','b']].forEach(function(p) {
var el = document.getElementById(prefix + p[1] + '-' + id);
if (!el) return;
if (p[0] === val) { el.style.background = chanStyles[val].bg; el.style.color = chanStyles[val].fg; }
else { el.style.background = '#fff'; el.style.color = '#94a3b8'; }
});
}
function setGChan(id, val) {
document.getElementById('gchan-val-' + id).value = val;
applyBtnStyles('gc', id, val);
}
function updateGlobalCount() { updateFooter(); }
// ── By-item tab helpers ──
var _openItemDd = null;
var _itemChan = {};
function setItemChan(itemId, val) {
_itemChan[itemId] = val;
['e','w','b'].forEach(function(k) {
var map = { e:'email', w:'whatsapp', b:'both' };
var el = document.getElementById('ichan-i' + k + '-' + itemId);
if (!el) return;
if (map[k] === val) { el.style.background = chanStyles[val].bg; el.style.color = chanStyles[val].fg; }
else { el.style.background = '#fff'; el.style.color = '#94a3b8'; }
});
document.querySelectorAll('input[name="item_suppliers[' + itemId + '][]"]:checked:not([disabled])').forEach(function(cb) {
var inp = document.getElementById('ichan-val-' + cb.value);
if (inp) inp.value = val;
});
}
function toggleItemDd(event, itemId) {
event.stopPropagation();
var dd = document.getElementById('idd-' + itemId);
if (!dd) return;
if (_openItemDd === itemId) {
dd.style.display = 'none';
_openItemDd = null;
return;
}
closeAllItemDd();
var btn = document.getElementById('idd-btn-' + itemId);
var rect = btn.getBoundingClientRect();
var ddWidth = 260;
var left = Math.min(rect.left, window.innerWidth - ddWidth - 8);
dd.style.left = left + 'px';
dd.style.top = (rect.bottom + 4) + 'px';
dd.style.width = ddWidth + 'px';
dd.style.display = 'block';
_openItemDd = itemId;
var inp = dd.querySelector('input[type="text"]');
if (inp) setTimeout(function(){ inp.focus(); }, 30);
}
function closeAllItemDd() {
if (_openItemDd !== null) {
var dd = document.getElementById('idd-' + _openItemDd);
if (dd) dd.style.display = 'none';
_openItemDd = null;
}
}
function filterItemDd(itemId, q) {
q = q.toLowerCase().trim();
var rows = document.querySelectorAll('.idd-row-' + itemId);
var visible = 0;
rows.forEach(function(row) {
var match = !q || (row.dataset.name || '').indexOf(q) !== -1;
row.style.display = match ? 'flex' : 'none';
if (match) visible++;
});
var noEl = document.getElementById('idd-no-' + itemId);
if (noEl) noEl.style.display = (visible === 0 && q) ? 'block' : 'none';
}
function updateItemDdLabel(itemId) {
var checked = document.querySelectorAll('[id^="isup-' + itemId + '-"]:checked:not([disabled])');
var label = document.getElementById('idd-label-' + itemId);
if (!label) return;
if (checked.length === 0) {
label.textContent = 'Select suppliers…';
label.style.color = '#94a3b8';
} else {
var names = Array.from(checked).map(function(c) { return c.dataset.supname || c.value; });
label.textContent = names.join(', ');
label.style.color = '#0f172a';
}
}
function onItemSupChange(itemId, supId) {
updateItemDdLabel(itemId);
// Activate/deactivate this item's channel picker
var anyCheckedForItem = document.querySelectorAll('input[name="item_suppliers[' + itemId + '][]"]:checked:not([disabled])').length > 0;
var picker = document.getElementById('ichan-item-btn-' + itemId);
if (picker) {
picker.style.opacity = anyCheckedForItem ? '1' : '.35';
picker.style.pointerEvents = anyCheckedForItem ? 'auto' : 'none';
}
// Apply this item's current channel to the newly checked supplier
var cb = document.getElementById('isup-' + itemId + '-' + supId);
if (cb && cb.checked) {
var chan = _itemChan[itemId] || 'email';
var inp = document.getElementById('ichan-val-' + supId);
if (inp) inp.value = chan;
}
updateFooter();
}
function setIChan(id, val) {
document.getElementById('ichan-val-' + id).value = val;
applyBtnStyles('ic', id, val);
}
document.addEventListener('click', function(e) {
if (_openItemDd === null) return;
var dd = document.getElementById('idd-' + _openItemDd);
var btn = document.getElementById('idd-btn-' + _openItemDd);
if (dd && !dd.contains(e.target) && btn && !btn.contains(e.target)) {
closeAllItemDd();
}
});
function updateFooter() {
var msg = document.getElementById('sup-footer-msg');
if (_supTab === 'global') {
var n = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])').length;
msg.textContent = n + ' supplier' + (n === 1 ? '' : 's') + ' selected';
} else {
var checked = document.querySelectorAll('input[name^="item_suppliers["]:checked:not([disabled])');
var supIds = new Set();
checked.forEach(function(c) { supIds.add(c.value); });
var n = supIds.size;
msg.textContent = n + ' supplier' + (n === 1 ? '' : 's') + ' assigned';
}
}
function submitSuppliers() {
if (_supTab === 'global') {
var checked = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])');
if (checked.length === 0) { showToast('Please select at least one supplier.', 'warn'); return; }
document.getElementById('sup-form').submit();
} else {
var checked = document.querySelectorAll('input[name^="item_suppliers["]:checked:not([disabled])');
if (checked.length === 0) { showToast('Please assign at least one supplier to an item.', 'warn'); return; }
showSummary();
}
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function showSummary() {
var chanLabel = {
email: '<span style="background:#eff6ff;color:#2563eb;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">Email</span>',
whatsapp: '<span style="background:#f0fdf4;color:#15803d;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">WhatsApp</span>',
both: '<span style="background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">Email + WA</span>',
};
var html = '<div style="font-size:13px;font-weight:700;color:#0f172a;margin-bottom:16px;">Review assignments before sending</div>';
var btns = document.querySelectorAll('[id^="idd-btn-"]');
btns.forEach(function(btn) {
var itemId = btn.id.replace('idd-btn-', '');
var checked = document.querySelectorAll('input[name="item_suppliers[' + itemId + '][]"]:checked:not([disabled])');
if (checked.length === 0) return;
var name = btn.dataset.itemname || ('Item ' + itemId);
var qty = btn.dataset.itemqty || '';
html += '<div style="margin-bottom:12px;border:1.5px solid #e2e8f0;border-radius:10px;overflow:hidden;">';
html += '<div style="padding:10px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;">';
html += '<div style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(name) + '</div>';
if (qty) html += '<div style="font-size:11px;color:#94a3b8;margin-top:1px;">Qty: ' + escHtml(qty) + '</div>';
html += '</div>';
checked.forEach(function(cb) {
var supName = cb.dataset.supname || cb.value;
var chan = (document.getElementById('ichan-val-' + cb.value) || {}).value || 'email';
var cl = chanLabel[chan] || '<span style="background:#f1f5f9;color:#475569;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">' + escHtml(chan) + '</span>';
html += '<div style="padding:9px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #f8fafc;">';
html += '<span style="font-size:13px;font-weight:600;color:#0f172a;">' + escHtml(supName) + '</span>';
html += cl;
html += '</div>';
});
html += '</div>';
});
document.getElementById('sup-summary-body').innerHTML = html;
document.getElementById('sup-modal-title').textContent = 'Confirm Assignments';
document.getElementById('sup-modal-subtitle').textContent = 'Review before sending to suppliers';
document.getElementById('sup-step2').style.display = 'none';
document.getElementById('sup-step3').style.display = 'flex';
}
function backToEdit() {
document.getElementById('sup-modal-title').textContent = 'Select Suppliers';
document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request';
document.getElementById('sup-step3').style.display = 'none';
document.getElementById('sup-step2').style.display = 'flex';
}
</script>

View File

@ -82,7 +82,6 @@
</a>
@foreach([
['purchase.suppliers.index', 'Suppliers'],
['purchase.requests.index', 'Purchase Requests'],
['purchase.orders.index', 'Purchase Orders'],
['purchase.grns.index', 'Goods Receipt (GRN)'],
['purchase.invoices.index', 'Supplier Invoices'],
@ -396,6 +395,13 @@ function dismissToast(el) {
<div style="font-size:13px;color:#64748b;line-height:1.5;" id="gdm-body">This action cannot be undone.</div>
</div>
</div>
<div id="gdm-input-wrap" style="display:none;padding:0 22px 12px;">
<label id="gdm-input-label" style="display:block;font-size:12px;font-weight:600;color:#64748b;margin-bottom:6px;"></label>
<input id="gdm-input" type="text" autocomplete="off" spellcheck="false"
oninput="gdmCheckInput()"
style="width:100%;box-sizing:border-box;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:9px;font-size:13px;font-weight:600;color:#0f172a;outline:none;letter-spacing:.02em;"
onfocus="this.style.borderColor='#dc2626'" onblur="this.style.borderColor='#e2e8f0'">
</div>
<div style="padding:16px 22px 20px;display:flex;gap:8px;justify-content:flex-end;">
<button onclick="closeGlobalDeleteModal()"
style="padding:9px 18px;border-radius:9px;border:1.5px solid #e2e8f0;background:#fff;
@ -429,8 +435,53 @@ function confirmDelete(form, title, body) {
};
}
// Callback-based version for AJAX delete flows
function confirmAction(title, body, onConfirm) {
document.getElementById('gdm-title').textContent = title || 'Are you sure?';
document.getElementById('gdm-body').textContent = body || 'This action cannot be undone.';
var modal = document.getElementById('global-delete-modal');
modal.style.display = 'flex';
document.getElementById('gdm-confirm-btn').onclick = function() {
closeGlobalDeleteModal();
onConfirm();
};
}
// Type-to-confirm version
var _gdmExpected = null;
function confirmWithInput(title, body, expectedText, onConfirm) {
document.getElementById('gdm-title').textContent = title || 'Are you sure?';
document.getElementById('gdm-body').textContent = body || 'This action cannot be undone.';
_gdmExpected = expectedText;
var wrap = document.getElementById('gdm-input-wrap');
var input = document.getElementById('gdm-input');
var btn = document.getElementById('gdm-confirm-btn');
document.getElementById('gdm-input-label').textContent = 'Type "' + expectedText + '" to confirm:';
input.value = '';
btn.disabled = true;
btn.style.opacity = '.4';
btn.style.cursor = 'not-allowed';
btn.style.boxShadow = 'none';
wrap.style.display = 'block';
document.getElementById('global-delete-modal').style.display = 'flex';
btn.onclick = function() { closeGlobalDeleteModal(); onConfirm(); };
setTimeout(function(){ input.focus(); }, 80);
}
function gdmCheckInput() {
var val = document.getElementById('gdm-input').value;
var btn = document.getElementById('gdm-confirm-btn');
var ok = _gdmExpected && val === _gdmExpected;
btn.disabled = !ok;
btn.style.opacity = ok ? '1' : '.4';
btn.style.cursor = ok ? 'pointer' : 'not-allowed';
btn.style.boxShadow = ok ? '0 3px 10px rgba(220,38,38,.35)' : 'none';
}
function closeGlobalDeleteModal() {
document.getElementById('global-delete-modal').style.display = 'none';
document.getElementById('gdm-input-wrap').style.display = 'none';
document.getElementById('gdm-input').value = '';
_gdmExpected = null;
_gdmForm = null;
}

View File

@ -51,9 +51,29 @@
{{ $pr->date ? \Carbon\Carbon::parse($pr->date)->format('d M Y') : '—' }}
</td>
<td style="padding:14px 18px;">
<svg width="16" height="16" fill="none" stroke="#cbd5e1" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
<div style="display:flex;align-items:center;gap:10px;">
<form method="POST" action="{{ route('purchase.requests.destroy', $pr) }}"
id="del-pr-{{ $pr->id }}" style="display:none;">
@csrf @method('DELETE')
</form>
<button type="button"
onclick="event.stopPropagation(); confirmWithInput(
'Delete {{ addslashes($pr->request_number) }}?',
'This will permanently remove the purchase request and all related data.',
'{{ addslashes($pr->request_number) }}',
function(){ document.getElementById('del-pr-{{ $pr->id }}').submit(); }
)"
style="width:30px;height:30px;border-radius:7px;border:1px solid #fecaca;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .12s;"
onmouseover="this.style.background='#fef2f2'" onmouseout="this.style.background='#fff'"
title="Delete">
<svg width="14" height="14" fill="none" stroke="#ef4444" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M8 7V4a1 1 0 011-1h6a1 1 0 011 1v3"/>
</svg>
</button>
<svg width="16" height="16" fill="none" stroke="#cbd5e1" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</div>
</td>
</tr>
@endforeach

View File

@ -14,10 +14,7 @@
<h1 style="font-size:22px;font-weight:700;color:#0f172a;">Purchase Pipeline</h1>
<p style="font-size:13px;color:#64748b;margin-top:3px;">Track every purchase from request to payment</p>
</div>
<a href="{{ route('purchase.requests.create') }}"
style="padding:10px 22px;background:#2563eb;color:#fff;border-radius:9px;font-size:13px;font-weight:700;text-decoration:none;">
+ New Request
</a>
<x-purchase.request-modal />
</div>
{{-- Tabs --}}

View File

@ -52,10 +52,7 @@
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="{{ route('purchase.requests.edit', $pr) }}"
style="font-size:12px;color:#2563eb;text-decoration:none;border:1px solid #bfdbfe;padding:6px 14px;border-radius:7px;white-space:nowrap;background:#eff6ff;">
✏️ Edit Request
</a>
<x-purchase.edit-request-modal :purchaseRequest="$pr" />
<a href="{{ route('purchase.requests.show', $pr) }}"
style="font-size:12px;color:#64748b;text-decoration:none;border:1px solid #e2e8f0;padding:6px 14px;border-radius:7px;white-space:nowrap;">
View Full Request
@ -402,225 +399,8 @@
</div>
</div>
{{-- ============================================================
SELECT SUPPLIERS MODAL
============================================================ --}}
<div id="supplier-modal" class="pipe-modal" role="dialog" aria-modal="true" aria-labelledby="sup-modal-title">
<div style="background:#fff;border-radius:20px;width:100%;max-width:680px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 30px 60px rgba(0,0,0,.3);overflow:hidden;">
<x-purchase.supplier-select-modal :pr="$pr" :suppliers="$suppliers" :selectedIds="$selectedIds" />
{{-- Header --}}
<div style="padding:20px 24px 0;border-bottom:1px solid #f1f5f9;flex-shrink:0;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px;">
<div>
<div id="sup-modal-title" style="font-size:17px;font-weight:700;color:#0f172a;">Select Suppliers</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">Choose who receives the quote request</div>
</div>
<button onclick="closeSupplierModal()" aria-label="Close"
style="width:32px;height:32px;border-radius:8px;border:none;background:#f1f5f9;cursor:pointer;font-size:18px;color:#64748b;display:flex;align-items:center;justify-content:center;flex-shrink:0;">×</button>
</div>
{{-- Tab bar --}}
<div style="display:flex;gap:0;">
<button id="stab-global" type="button" onclick="switchSupTab('global')"
style="padding:10px 20px;font-size:13px;font-weight:700;border:none;background:none;cursor:pointer;color:#2563eb;border-bottom:2px solid #2563eb;margin-bottom:-1px;">
Full Order
</button>
<button id="stab-item" type="button" onclick="switchSupTab('item')"
style="padding:10px 20px;font-size:13px;font-weight:700;border:none;background:none;cursor:pointer;color:#94a3b8;border-bottom:2px solid transparent;margin-bottom:-1px;">
By Item
</button>
</div>
</div>
{{-- Single form wrapping both panes --}}
<form id="sup-form" action="{{ route('purchase.requests.rfq.select', $pr) }}" method="POST"
style="flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
@csrf
<input type="hidden" name="mode" id="sup-mode" value="global">
{{-- ── GLOBAL PANE ── --}}
<div id="sup-global-pane" style="flex:1;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;">
{{-- Search --}}
<div style="padding:14px 24px 10px;flex-shrink:0;">
<div style="position:relative;">
<svg style="position:absolute;left:11px;top:50%;transform:translateY(-50%);pointer-events:none;"
width="14" height="14" fill="none" stroke="#94a3b8" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35"/>
</svg>
<input id="sup-search" type="text" placeholder="Search suppliers…" oninput="filterGlobalSups(this.value)"
style="width:100%;box-sizing:border-box;padding:9px 12px 9px 34px;border:1px solid #e2e8f0;border-radius:9px;font-size:13px;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
</div>
</div>
<div id="sup-list" style="flex:1;">
@forelse($suppliers as $sup)
@php $alreadyAdded = in_array($sup->id, $selectedIds); @endphp
<div class="g-sup-item" data-name="{{ strtolower($sup->name) }}"
style="padding:11px 24px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f8fafc;transition:background .1s;
{{ $alreadyAdded ? 'opacity:.45;pointer-events:none;' : '' }}">
<input type="checkbox" name="supplier_ids[]" value="{{ $sup->id }}" id="gsup-{{ $sup->id }}"
{{ $alreadyAdded ? 'disabled checked' : '' }}
onchange="toggleGlobalChan({{ $sup->id }}, this.checked); updateGlobalCount();"
style="width:17px;height:17px;accent-color:#2563eb;cursor:pointer;flex-shrink:0;">
<label for="gsup-{{ $sup->id }}" style="flex:1;cursor:{{ $alreadyAdded ? 'default' : 'pointer' }};">
<div style="font-size:13px;font-weight:600;color:#0f172a;">
{{ $sup->name }}
@if($alreadyAdded)
<span style="font-size:10px;background:#dcfce7;color:#15803d;padding:1px 6px;border-radius:10px;margin-left:6px;font-weight:700;">Added</span>
@endif
</div>
@if($sup->email || $sup->phone)
<div style="font-size:11px;color:#94a3b8;margin-top:1px;">
{{ collect([$sup->email, $sup->phone])->filter()->implode(' · ') }}
</div>
@endif
</label>
@if(!$alreadyAdded)
<div id="gchan-{{ $sup->id }}" style="display:none;flex-shrink:0;">
<input type="hidden" name="channel_{{ $sup->id }}" id="gchan-val-{{ $sup->id }}" value="email">
<div style="display:flex;border:1px solid #e2e8f0;border-radius:7px;overflow:hidden;">
<span onclick="setGChan({{ $sup->id }},'email')" id="gce-{{ $sup->id }}"
style="padding:5px 9px;font-size:10px;font-weight:700;cursor:pointer;background:#eff6ff;color:#2563eb;">Email</span>
<span onclick="setGChan({{ $sup->id }},'whatsapp')" id="gcw-{{ $sup->id }}"
style="padding:5px 9px;font-size:10px;font-weight:700;cursor:pointer;background:#fff;color:#94a3b8;border-left:1px solid #e2e8f0;">WA</span>
<span onclick="setGChan({{ $sup->id }},'both')" id="gcb-{{ $sup->id }}"
style="padding:5px 9px;font-size:10px;font-weight:700;cursor:pointer;background:#fff;color:#94a3b8;border-left:1px solid #e2e8f0;">Both</span>
</div>
</div>
@endif
</div>
@empty
<div style="padding:40px;text-align:center;color:#94a3b8;font-size:13px;">No active suppliers found.</div>
@endforelse
<div id="no-sup-msg" style="display:none;padding:30px;text-align:center;color:#94a3b8;font-size:13px;">No suppliers match your search.</div>
</div>
</div>
{{-- ── BY ITEM PANE ── --}}
<div id="sup-item-pane" style="flex:1;overflow-y:auto;overscroll-behavior:contain;display:none;flex-direction:column;">
@if($pr->items->isEmpty())
<div style="padding:40px;text-align:center;color:#94a3b8;font-size:13px;">This request has no items yet.</div>
@else
{{-- Column headers --}}
<div style="display:grid;grid-template-columns:1fr 250px;gap:12px;padding:9px 24px;background:#f8fafc;border-bottom:1px solid #e2e8f0;flex-shrink:0;">
<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;">Item</div>
<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;">Assign Suppliers</div>
</div>
{{-- Item rows --}}
@foreach($pr->items as $item)
<div style="display:grid;grid-template-columns:1fr 250px;gap:12px;align-items:center;padding:13px 24px;border-bottom:1px solid #f8fafc;">
{{-- Left: item info --}}
<div>
<div style="font-size:13px;font-weight:600;color:#0f172a;line-height:1.3;">{{ $item->description }}</div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">
Qty: {{ rtrim(rtrim(number_format($item->quantity_required,2),'0'),'.') }}{{ $item->unit ? ' '.$item->unit : '' }}
</div>
</div>
{{-- Right: multi-select dropdown trigger --}}
<div>
<button type="button" id="idd-btn-{{ $item->id }}"
onclick="toggleItemDd(event,{{ $item->id }})"
style="width:100%;padding:8px 11px;border:1.5px solid #e2e8f0;border-radius:8px;background:#fff;cursor:pointer;
display:flex;align-items:center;justify-content:space-between;gap:6px;text-align:left;transition:border .15s;">
<span id="idd-label-{{ $item->id }}" style="font-size:12px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
Select suppliers…
</span>
<svg width="11" height="11" fill="none" stroke="#94a3b8" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
</div>
</div>
@endforeach
{{-- Per-supplier channel settings --}}
<div id="item-chan-section" style="padding:14px 24px;background:#fafafa;border-top:2px solid #f1f5f9;display:none;flex-shrink:0;">
<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Notify via</div>
@foreach($suppliers as $sup)
<div id="ic-{{ $sup->id }}" style="display:none;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:1px solid #f1f5f9;">
<span style="font-size:12px;font-weight:600;color:#0f172a;">{{ $sup->name }}</span>
<div>
<input type="hidden" name="channel_{{ $sup->id }}" id="ichan-val-{{ $sup->id }}" value="email">
<div style="display:flex;border:1px solid #e2e8f0;border-radius:7px;overflow:hidden;">
<span onclick="setIChan({{ $sup->id }},'email')" id="ice-{{ $sup->id }}"
style="padding:5px 10px;font-size:10px;font-weight:700;cursor:pointer;background:#eff6ff;color:#2563eb;">Email</span>
<span onclick="setIChan({{ $sup->id }},'whatsapp')" id="icw-{{ $sup->id }}"
style="padding:5px 10px;font-size:10px;font-weight:700;cursor:pointer;background:#fff;color:#94a3b8;border-left:1px solid #e2e8f0;">WA</span>
<span onclick="setIChan({{ $sup->id }},'both')" id="icb-{{ $sup->id }}"
style="padding:5px 10px;font-size:10px;font-weight:700;cursor:pointer;background:#fff;color:#94a3b8;border-left:1px solid #e2e8f0;">Both</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Dropdown panels: fixed-positioned, one per item must be inside form so checkboxes are submitted --}}
@foreach($pr->items as $item)
<div id="idd-{{ $item->id }}"
style="display:none;position:fixed;z-index:99999;background:#fff;border:1.5px solid #e2e8f0;
border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.18);overflow:hidden;min-width:240px;">
{{-- Search --}}
<div style="padding:10px 10px 6px;">
<input type="text" placeholder="Search suppliers…"
oninput="filterItemDd({{ $item->id }}, this.value)"
style="width:100%;box-sizing:border-box;padding:7px 10px;border:1px solid #e2e8f0;border-radius:7px;font-size:12px;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
</div>
{{-- Supplier list --}}
<div style="max-height:210px;overflow-y:auto;padding-bottom:4px;">
@forelse($suppliers as $sup)
@php $alreadyAdded = in_array($sup->id, $selectedIds); @endphp
<label class="idd-row-{{ $item->id }}" data-name="{{ strtolower($sup->name) }}"
style="display:flex;align-items:center;gap:10px;padding:8px 12px;cursor:{{ $alreadyAdded ? 'default' : 'pointer' }};
{{ $alreadyAdded ? 'opacity:.45;' : '' }}"
onmouseover="{{ $alreadyAdded ? '' : "this.style.background='#f8fafc'" }}"
onmouseout="this.style.background='transparent'">
<input type="checkbox" name="item_suppliers[{{ $item->id }}][]" value="{{ $sup->id }}"
data-supname="{{ $sup->name }}"
id="isup-{{ $item->id }}-{{ $sup->id }}"
{{ $alreadyAdded ? 'disabled checked' : '' }}
onchange="onItemSupChange({{ $item->id }}, {{ $sup->id }})"
style="width:15px;height:15px;accent-color:#2563eb;cursor:{{ $alreadyAdded ? 'default' : 'pointer' }};flex-shrink:0;">
<div style="min-width:0;">
<div style="font-size:12px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ $sup->name }}
@if($alreadyAdded)
<span style="font-size:9px;background:#dcfce7;color:#15803d;padding:1px 5px;border-radius:8px;margin-left:4px;">Added</span>
@endif
</div>
@if($sup->email)
<div style="font-size:10px;color:#94a3b8;">{{ $sup->email }}</div>
@endif
</div>
</label>
@empty
<div style="padding:16px;text-align:center;color:#94a3b8;font-size:12px;">No suppliers found.</div>
@endforelse
<div id="idd-no-{{ $item->id }}" style="display:none;padding:14px;text-align:center;color:#94a3b8;font-size:12px;">No results</div>
</div>
</div>
@endforeach
</form>
{{-- Footer --}}
<div style="padding:14px 24px;border-top:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;background:#fafafa;">
<div style="font-size:12px;color:#64748b;" id="sup-footer-msg">0 selected</div>
<div style="display:flex;gap:10px;">
<button type="button" onclick="closeSupplierModal()"
style="padding:8px 18px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;font-weight:600;color:#64748b;background:#fff;cursor:pointer;">
Cancel
</button>
<button type="button" onclick="submitSuppliers()"
style="padding:8px 22px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">
Save &amp; Continue
</button>
</div>
</div>
</div>
</div>
<script>
// ---- Signature modal ----
@ -678,208 +458,5 @@
});
}());
// ---- Supplier modal ----
var _supTab = 'global';
function openSupplierModal() {
document.getElementById('supplier-modal').classList.add('open');
switchSupTab('global');
document.getElementById('sup-search').focus();
}
function closeSupplierModal() {
closeAllItemDd();
document.getElementById('supplier-modal').classList.remove('open');
}
document.getElementById('supplier-modal').addEventListener('click', function(e) {
if (e.target === this) closeSupplierModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { closeSignModal(); closeSupplierModal(); }
});
// Tab switching
function switchSupTab(tab) {
_supTab = tab;
document.getElementById('sup-mode').value = tab === 'item' ? 'by_item' : 'global';
document.getElementById('sup-global-pane').style.display = tab === 'global' ? 'flex' : 'none';
document.getElementById('sup-item-pane').style.display = tab === 'item' ? 'flex' : 'none';
var gBtn = document.getElementById('stab-global');
var iBtn = document.getElementById('stab-item');
if (tab === 'global') {
gBtn.style.color = '#2563eb'; gBtn.style.borderBottomColor = '#2563eb';
iBtn.style.color = '#94a3b8'; iBtn.style.borderBottomColor = 'transparent';
} else {
iBtn.style.color = '#2563eb'; iBtn.style.borderBottomColor = '#2563eb';
gBtn.style.color = '#94a3b8'; gBtn.style.borderBottomColor = 'transparent';
}
updateFooter();
}
// ── Global tab helpers ──
function filterGlobalSups(q) {
q = q.toLowerCase().trim();
var items = document.querySelectorAll('.g-sup-item');
var visible = 0;
items.forEach(function(item) {
var match = !q || item.dataset.name.indexOf(q) !== -1;
item.style.display = match ? 'flex' : 'none';
if (match) visible++;
});
document.getElementById('no-sup-msg').style.display = (visible === 0 && q) ? 'block' : 'none';
}
function toggleGlobalChan(id, checked) {
var el = document.getElementById('gchan-' + id);
if (el) el.style.display = checked ? 'block' : 'none';
updateFooter();
}
var chanStyles = {
email: { bg:'#eff6ff', fg:'#2563eb' },
whatsapp: { bg:'#f0fdf4', fg:'#15803d' },
both: { bg:'#fef3c7', fg:'#92400e' },
};
function applyBtnStyles(prefix, id, val) {
[['email','e'],['whatsapp','w'],['both','b']].forEach(function(p) {
var el = document.getElementById(prefix + p[1] + '-' + id);
if (!el) return;
if (p[0] === val) { el.style.background = chanStyles[val].bg; el.style.color = chanStyles[val].fg; }
else { el.style.background = '#fff'; el.style.color = '#94a3b8'; }
});
}
function setGChan(id, val) {
document.getElementById('gchan-val-' + id).value = val;
applyBtnStyles('gc', id, val);
}
function updateGlobalCount() { updateFooter(); }
// ── By-item tab helpers ──
var _openItemDd = null;
function toggleItemDd(event, itemId) {
event.stopPropagation();
var dd = document.getElementById('idd-' + itemId);
if (!dd) return;
// close if already open
if (_openItemDd === itemId) {
dd.style.display = 'none';
_openItemDd = null;
return;
}
// close any other open dropdown
closeAllItemDd();
// position relative to the trigger button
var btn = document.getElementById('idd-btn-' + itemId);
var rect = btn.getBoundingClientRect();
var ddWidth = 260;
var left = Math.min(rect.left, window.innerWidth - ddWidth - 8);
dd.style.left = left + 'px';
dd.style.top = (rect.bottom + 4) + 'px';
dd.style.width = ddWidth + 'px';
dd.style.display = 'block';
_openItemDd = itemId;
// focus search input
var inp = dd.querySelector('input[type="text"]');
if (inp) setTimeout(function(){ inp.focus(); }, 30);
}
function closeAllItemDd() {
if (_openItemDd !== null) {
var dd = document.getElementById('idd-' + _openItemDd);
if (dd) dd.style.display = 'none';
_openItemDd = null;
}
}
function filterItemDd(itemId, q) {
q = q.toLowerCase().trim();
var rows = document.querySelectorAll('.idd-row-' + itemId);
var visible = 0;
rows.forEach(function(row) {
var match = !q || (row.dataset.name || '').indexOf(q) !== -1;
row.style.display = match ? 'flex' : 'none';
if (match) visible++;
});
var noEl = document.getElementById('idd-no-' + itemId);
if (noEl) noEl.style.display = (visible === 0 && q) ? 'block' : 'none';
}
function updateItemDdLabel(itemId) {
var checked = document.querySelectorAll('[id^="isup-' + itemId + '-"]:checked:not([disabled])');
var label = document.getElementById('idd-label-' + itemId);
if (!label) return;
if (checked.length === 0) {
label.textContent = 'Select suppliers…';
label.style.color = '#94a3b8';
} else {
var names = Array.from(checked).map(function(c) { return c.dataset.supname || c.value; });
label.textContent = names.join(', ');
label.style.color = '#0f172a';
}
}
function onItemSupChange(itemId, supId) {
// update dropdown trigger label for this item
updateItemDdLabel(itemId);
// show/hide channel row for this supplier (appears if checked in ANY item)
var anyChecked = document.querySelectorAll('input[name^="item_suppliers["][value="' + supId + '"]:checked:not([disabled])').length > 0;
var row = document.getElementById('ic-' + supId);
if (row) row.style.display = anyChecked ? 'flex' : 'none';
// show/hide entire channel section
var anyItemChecked = document.querySelectorAll('#sup-item-pane input[type="checkbox"]:checked:not([disabled])').length > 0;
document.getElementById('item-chan-section').style.display = anyItemChecked ? 'block' : 'none';
updateFooter();
}
function setIChan(id, val) {
document.getElementById('ichan-val-' + id).value = val;
applyBtnStyles('ic', id, val);
}
// close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (_openItemDd === null) return;
var dd = document.getElementById('idd-' + _openItemDd);
var btn = document.getElementById('idd-btn-' + _openItemDd);
if (dd && !dd.contains(e.target) && btn && !btn.contains(e.target)) {
closeAllItemDd();
}
});
// ── Footer count ──
function updateFooter() {
var msg = document.getElementById('sup-footer-msg');
if (_supTab === 'global') {
var n = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])').length;
msg.textContent = n + ' supplier' + (n === 1 ? '' : 's') + ' selected';
} else {
var checked = document.querySelectorAll('#sup-item-pane input[type="checkbox"]:checked:not([disabled])');
var supIds = new Set();
checked.forEach(function(c) { supIds.add(c.value); });
var n = supIds.size;
msg.textContent = n + ' supplier' + (n === 1 ? '' : 's') + ' assigned';
}
}
// ── Submit ──
function submitSuppliers() {
if (_supTab === 'global') {
var checked = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])');
if (checked.length === 0) { showToast('Please select at least one supplier.', 'warn'); return; }
} else {
var checked = document.querySelectorAll('#sup-item-pane input[type="checkbox"]:checked:not([disabled])');
if (checked.length === 0) { showToast('Please assign at least one supplier to an item.', 'warn'); return; }
}
document.getElementById('sup-form').submit();
}
</script>
@endsection

View File

@ -8,9 +8,7 @@
<h1 class="page-title">Purchase Requests</h1>
<p class="page-subtitle">Manage internal purchase requests</p>
</div>
<a href="{{ route('purchase.requests.create') }}" class="btn-primary">
+ New Request
</a>
<x-purchase.request-modal />
</div>
<div class="table-wrapper overflow-x-auto">
@ -20,8 +18,8 @@
<th>Request #</th>
<th>Date</th>
<th>Department</th>
<th>Item</th>
<th>Quantity</th>
<th>First Item</th>
<th>Qty / Items</th>
<th>Status</th>
<th>Requested By</th>
<th>Actions</th>
@ -30,11 +28,13 @@
<tbody>
@forelse($requests as $request)
<tr>
<td class="font-mono text-gray-700">{{ $request->request_number ?? '#' . $request->id }}</td>
<td class="font-mono text-gray-700">
<a href="{{ route('purchase.requests.show', $request) }}" class="text-blue-600 hover:underline">{{ $request->request_number ?? '#' . $request->id }}</a>
</td>
<td>{{ $request->date ? $request->date->format('d M Y') : '' }}</td>
<td>{{ $request->department }}</td>
<td class="text-gray-800">{{ $request->item->item_name ?? $request->item_name }}</td>
<td>{{ $request->quantity }} {{ $request->unit_of_measure }}</td>
<td class="text-gray-800">{{ $request->items->first()->description ?? '—' }}</td>
<td>{{ $request->items->count() > 1 ? $request->items->count() . ' items' : ($request->items->first() ? number_format($request->items->first()->quantity_required, 2) . ' ' . $request->items->first()->unit : '—') }}</td>
<td>
@php
$badgeClass = match($request->status) {
@ -62,7 +62,7 @@
<button type="submit" class="btn-danger btn-sm">Reject</button>
</form>
@endif
<a href="{{ route('purchase.requests.edit', $request) }}" class="btn-secondary btn-sm">Edit</a>
<x-purchase.edit-request-modal :purchaseRequest="$request" />
<form action="{{ route('purchase.requests.destroy', $request) }}" method="POST"
onsubmit="confirmDelete(this,'Delete this purchase request?','This request will be permanently removed.'); return false;">
@csrf

View File

@ -12,7 +12,7 @@
<a href="{{ route('purchase.requests.print', $purchaseRequest) }}" target="_blank"
class="btn-primary">Print MPR Form</a>
@if($purchaseRequest->status === 'pending')
<a href="{{ route('purchase.requests.edit', $purchaseRequest) }}" class="btn-secondary">Edit</a>
<x-purchase.edit-request-modal :purchaseRequest="$purchaseRequest" />
<form action="{{ route('purchase.requests.approve', $purchaseRequest) }}" method="POST">
@csrf @method('PATCH')
<button type="submit" class="btn-success">Approve</button>

View File

@ -59,98 +59,13 @@
</div>
@endif
{{-- Invite more suppliers --}}
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">
{{ $invitations->count() ? 'Add More Suppliers' : 'Select Suppliers' }}
</div>
<input type="text" id="supplier-search" placeholder="Search suppliers…" oninput="filterSuppliers(this.value)"
style="width:100%;padding:9px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;margin-bottom:12px;box-sizing:border-box;">
<form method="POST" action="{{ route('purchase.requests.rfq.store', $request) }}" id="rfq-form">
@csrf
<input type="hidden" name="_token" value="{{ csrf_token() }}">
<div id="supplier-list" style="display:flex;flex-direction:column;gap:6px;max-height:400px;overflow-y:auto;padding-right:2px;">
@php $alreadyIds = $invitations->pluck('supplier_id')->toArray(); @endphp
@forelse($suppliers as $supplier)
@php $already = in_array($supplier->id, $alreadyIds); @endphp
<div class="sup-row" data-name="{{ strtolower($supplier->name) }}"
style="border:1.5px solid #e2e8f0;border-radius:10px;padding:12px 14px;transition:border-color .15s;{{ $already ? 'opacity:.45;' : '' }}">
<label style="display:flex;align-items:center;gap:12px;cursor:{{ $already ? 'default' : 'pointer' }};">
<input type="checkbox" name="supplier_ids[]" value="{{ $supplier->id }}"
data-sid="{{ $supplier->id }}" onchange="toggleRow(this)"
{{ $already ? 'disabled' : '' }}
style="width:17px;height:17px;cursor:pointer;flex-shrink:0;">
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:#0f172a;">{{ $supplier->name }}</div>
<div style="font-size:11px;color:#64748b;margin-top:1px;">
{{ $supplier->email ?: '—' }} · {{ $supplier->phone ?: '—' }}
</div>
</div>
@if($already)
<span style="font-size:11px;font-weight:600;color:#64748b;background:#f1f5f9;padding:3px 10px;border-radius:20px;">Already invited</span>
@endif
</label>
{{-- Channel selector (hidden until checked) --}}
<div id="ch-{{ $supplier->id }}" style="display:none;margin-top:10px;padding-top:10px;border-top:1px dashed #e2e8f0;">
<div style="font-size:11px;font-weight:700;color:#64748b;margin-bottom:8px;">Send via:</div>
<div style="display:flex;gap:8px;">
@foreach(['whatsapp' => 'WhatsApp', 'email' => 'Email', 'both' => 'Both'] as $val => $lbl)
<label style="flex:1;cursor:pointer;">
<input type="radio" name="channel_{{ $supplier->id }}" value="{{ $val }}" {{ $val === 'both' ? 'checked' : '' }}
style="display:none;" id="ch-{{ $supplier->id }}-{{ $val }}"
onchange="styleChannelBtns({{ $supplier->id }})">
<span id="chbtn-{{ $supplier->id }}-{{ $val }}"
onclick="document.getElementById('ch-{{ $supplier->id }}-{{ $val }}').checked=true;styleChannelBtns({{ $supplier->id }});"
style="display:block;text-align:center;padding:7px 6px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;
border:1.5px solid {{ $val === 'both' ? '#0ea5e9' : '#e2e8f0' }};
background:{{ $val === 'both' ? '#eff6ff' : '#fff' }};
color:{{ $val === 'both' ? '#0284c7' : '#64748b' }};">
{{ $lbl }}
</span>
</label>
@endforeach
</div>
</div>
</div>
@empty
<p style="text-align:center;color:#94a3b8;padding:24px;">No active suppliers found. Add suppliers first.</p>
@endforelse
</div>
<button type="submit"
style="width:100%;margin-top:20px;padding:13px;background:linear-gradient(135deg,#0ea5e9,#0284c7);color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;cursor:pointer;">
Send Invitations
</button>
</form>
@php $alreadyIds = $invitations->pluck('supplier_id')->toArray(); @endphp
<x-purchase.supplier-invite-list
:suppliers="$suppliers"
:alreadyIds="$alreadyIds"
:formAction="route('purchase.requests.rfq.store', $request)"
/>
</div>
</div>
</div>
<script>
function filterSuppliers(q) {
document.querySelectorAll('.sup-row').forEach(function(row) {
row.style.display = row.dataset.name.includes(q.toLowerCase()) ? '' : 'none';
});
}
function toggleRow(cb) {
var ch = document.getElementById('ch-' + cb.dataset.sid);
if (ch) ch.style.display = cb.checked ? 'block' : 'none';
}
function styleChannelBtns(sid) {
['whatsapp','email','both'].forEach(function(v) {
var inp = document.getElementById('ch-' + sid + '-' + v);
var btn = document.getElementById('chbtn-' + sid + '-' + v);
if (!inp || !btn) return;
if (inp.checked) {
btn.style.borderColor = '#0ea5e9'; btn.style.background = '#eff6ff'; btn.style.color = '#0284c7';
} else {
btn.style.borderColor = '#e2e8f0'; btn.style.background = '#fff'; btn.style.color = '#64748b';
}
});
}
</script>
@endsection

View File

@ -3,318 +3,707 @@
@section('title', 'Settings — Projects')
@section('content')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<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; }
.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; cursor:pointer; background:white; user-select:none; transition:background .15s; }
.proj-header:hover { background:#f8fafc; }
.proj-body { display:none; border-top:1px solid #e2e8f0; }
.proj-body.open { display:block; }
.proj-chevron { transition:transform .2s; flex-shrink:0; }
.proj-chevron.open { transform:rotate(90deg); }
.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-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; }
.field-error { color:#dc2626; font-size:12px; margin-top:4px; display:none; }
.stat-card { background:white; border:1px solid #e2e8f0; border-radius:0.75rem; padding:1.25rem 1.5rem; }
/* Location modal */
#loc-modal-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,0.55); z-index:9999; align-items:center; justify-content:center; }
#loc-modal-overlay.open { display:flex; }
#loc-modal-box { background:white; border-radius:1rem; width:940px; max-width:96vw; max-height:93vh; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 25px 60px rgba(0,0,0,0.25); }
#loc-map { height:100%; width:100%; min-height:420px; }
.leaflet-container { font-family:inherit; }
</style>
<div class="mb-6">
<h1 class="page-title">Settings Projects</h1>
<p class="page-subtitle">Manage projects and their sub-locations.</p>
{{-- Page header --}}
<div class="mb-5" style="display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:12px;">
<div>
<h1 class="page-title">Projects &amp; Locations</h1>
<p class="page-subtitle">Manage projects and their physical sub-locations with addresses and GPS coordinates.</p>
</div>
</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>
{{-- Stat boxes --}}
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:28px;">
<div class="stat-card" style="border-top:3px solid #3b82f6;">
<div style="display:flex; align-items:center; gap:12px;">
<div style="width:40px;height:40px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="18" height="18" fill="none" stroke="#3b82f6" viewBox="0 0 24 24"><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>
</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>
<div style="font-size:28px;font-weight:700;color:#1e293b;line-height:1;">{{ $stats['total_projects'] }}</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">Total Projects</div>
</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 class="stat-card" style="border-top:3px solid #22c55e;">
<div style="display:flex; align-items:center; gap:12px;">
<div style="width:40px;height:40px;background:#f0fdf4;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="18" height="18" fill="none" stroke="#22c55e" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</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>
<div style="font-size:28px;font-weight:700;color:#1e293b;line-height:1;">{{ $stats['active_projects'] }}</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">Active Projects</div>
</div>
</div>
</div>
<div class="stat-card" style="border-top:3px solid #8b5cf6;">
<div style="display:flex; align-items:center; gap:12px;">
<div style="width:40px;height:40px;background:#f5f3ff;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="18" height="18" fill="none" stroke="#8b5cf6" viewBox="0 0 24 24"><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"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<div>
<div style="font-size:28px;font-weight:700;color:#1e293b;line-height:1;">{{ $stats['total_locations'] }}</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">Total Locations</div>
</div>
</div>
</div>
<div class="stat-card" style="border-top:3px solid #f59e0b;">
<div style="display:flex; align-items:center; gap:12px;">
<div style="width:40px;height:40px;background:#fffbeb;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="18" height="18" fill="none" stroke="#f59e0b" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</div>
<div>
<div style="font-size:28px;font-weight:700;color:#1e293b;line-height:1;">{{ $stats['active_locations'] }}</div>
<div style="font-size:12px;color:#64748b;margin-top:3px;">Active Locations</div>
</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());
{{-- Add Project --}}
<div class="card card-body mb-5" style="padding:1.125rem 1.25rem;">
<div style="display:flex; gap:10px; align-items:flex-start;">
<div style="flex:1;">
<input id="new-project-input" type="text" class="form-input" style="width:100%;"
placeholder="New project name…"
onkeydown="if(event.key==='Enter') addProject()">
<p id="new-project-error" class="field-error"></p>
</div>
<button type="button" onclick="addProject()" class="btn-primary" style="white-space:nowrap; flex-shrink:0; padding:0.5rem 1.25rem;">
+ Add Project
</button>
</div>
</div>
// 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;
{{-- Build location data map for safe JS passing --}}
@php
$allLocsData = [];
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,
];
}
}
$allLocsJson = json_encode($allLocsData);
@endphp
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>';
{{-- Projects accordion --}}
<div id="projects-list">
@forelse($projects as $project)
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>';
<div class="proj-card" id="proj-card-{{ $project->id }}">
{{-- Header --}}
<div class="proj-header" onclick="toggleProject({{ $project->id }})">
<div style="display:flex; align-items:center; gap:10px; flex:1; min-width:0;">
<svg class="proj-chevron" id="chevron-{{ $project->id }}" width="14" height="14" fill="none" stroke="#94a3b8" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<svg width="15" height="15" fill="none" stroke="{{ $project->is_active ? '#2563eb' : '#9ca3af' }}" viewBox="0 0 24 24" style="flex-shrink:0;" id="proj-icon-{{ $project->id }}">
<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 id="proj-name-{{ $project->id }}" style="font-size:14px; font-weight:600; color:#1e293b;">{{ $project->name }}</span>
<span id="proj-inactive-badge-{{ $project->id }}" class="badge-inactive" style="{{ $project->is_active ? 'display:none' : '' }}">Inactive</span>
<span id="proj-loc-count-{{ $project->id }}" style="font-size:12px; color:#94a3b8; margin-left:2px;">
{{ $project->locations->count() }} {{ Str::plural('location', $project->locations->count()) }}
</span>
</div>
<div style="display:flex; gap:6px; 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">Edit</button>
<button type="button" onclick="deleteProject({{ $project->id }}, '{{ addslashes($project->name) }}')"
class="btn-danger btn-sm">Delete</button>
</div>
</div>
{{-- Project edit form --}}
<div class="proj-edit-wrap" id="proj-edit-{{ $project->id }}">
<input id="edit-proj-name-{{ $project->id }}" type="text" 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="checkbox" id="edit-proj-active-{{ $project->id }}" {{ $project->is_active ? 'checked' : '' }} style="width:14px; height:14px;">
Active
</label>
<button type="button" onclick="saveProject({{ $project->id }})" class="btn-primary" style="padding:5px 14px; font-size:12px; white-space:nowrap;">Save</button>
<button type="button" onclick="closeEditProject({{ $project->id }})" class="btn-secondary btn-sm">Cancel</button>
<p id="edit-proj-error-{{ $project->id }}" class="field-error" style="margin:0;"></p>
</div>
{{-- Body --}}
<div class="proj-body" id="proj-body-{{ $project->id }}">
{{-- Location rows (sorted alphabetically by server) --}}
<div id="loc-list-{{ $project->id }}">
@forelse($project->locations as $location)
<div id="loc-wrap-{{ $location->id }}">
<div class="loc-row" id="loc-row-{{ $location->id }}">
<div style="display:flex; align-items:flex-start; gap:10px; flex:1; min-width:0;">
<svg width="14" height="14" fill="none" stroke="{{ $location->is_active ? '#22c55e' : '#9ca3af' }}" viewBox="0 0 24 24" id="loc-icon-{{ $location->id }}" style="flex-shrink:0; margin-top:3px;">
<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"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<div style="flex:1; min-width:0;">
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap;">
<span id="loc-name-{{ $location->id }}" style="font-size:13px; font-weight:600; color:#1e293b;">{{ $location->name }}</span>
<span id="loc-inactive-{{ $location->id }}" class="badge-inactive" style="{{ $location->is_active ? 'display:none' : '' }}">Inactive</span>
</div>
<div id="loc-address-{{ $location->id }}" style="font-size:12px; color:#64748b; margin-top:2px;{{ $location->address ? '' : 'display:none;' }}">{{ $location->address }}</div>
<div id="loc-gps-{{ $location->id }}" style="font-size:11px; color:#94a3b8; margin-top:1px; font-family:monospace;{{ ($location->latitude && $location->longitude) ? '' : 'display:none;' }}">
@if($location->latitude && $location->longitude)
{{ number_format((float)$location->latitude, 6) }}°,&nbsp;{{ number_format((float)$location->longitude, 6) }}°
@endif
</div>
</div>
</div>
<div style="display:flex; gap:4px; flex-shrink:0; margin-top:1px;">
<button type="button" onclick="openLocModal({{ $location->id }}, {{ $project->id }})"
class="btn-secondary btn-sm" style="padding:3px 8px; font-size:12px;">Edit</button>
<button type="button" onclick="deleteLoc({{ $project->id }}, {{ $location->id }}, '{{ addslashes($location->name) }}')"
class="btn-danger btn-sm" style="padding:3px 8px; font-size:12px;">Delete</button>
</div>
</div>
</div>
@empty
<div id="loc-empty-{{ $project->id }}" style="padding:14px 1.25rem 14px 2.5rem; color:#9ca3af; font-size:13px; border-bottom:1px solid #f1f5f9;">
No locations yet add the first one below.
</div>
@endforelse
</div>
{{-- Add Location button --}}
<div style="padding:0.875rem 1.25rem; background:#f8fafc; border-top:1px solid #e2e8f0;">
<button type="button" onclick="openLocModal(null, {{ $project->id }})"
class="btn-primary" style="padding:0.4rem 1rem; font-size:13px;">
+ Add Location to {{ $project->name }}
</button>
</div>
</div>{{-- end proj-body --}}
</div>{{-- end proj-card --}}
@empty
<div id="no-projects-msg" class="card card-body" style="text-align:center; padding:3rem; color:#9ca3af;">
<svg width="40" height="40" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="margin:0 auto 12px; display:block; opacity:.3;">
<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 your first project above.
</div>
@endforelse
</div>
{{-- ═══════════════ Location Map Modal ═══════════════ --}}
<div id="loc-modal-overlay" onclick="if(event.target===this)closeLocModal()">
<div id="loc-modal-box">
{{-- Header --}}
<div style="display:flex; align-items:center; justify-content:space-between; padding:1rem 1.5rem; border-bottom:1px solid #e2e8f0; flex-shrink:0;">
<h2 id="loc-modal-title" style="font-size:16px; font-weight:700; color:#0f172a; margin:0;"></h2>
<button type="button" onclick="closeLocModal()" style="background:none; border:none; cursor:pointer; color:#94a3b8; padding:4px; line-height:0;">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Two-panel body --}}
<div style="display:flex; flex:1; overflow:hidden; min-height:0;">
{{-- Left: form fields --}}
<div style="width:310px; flex-shrink:0; padding:1.25rem 1.25rem 1rem; overflow-y:auto; border-right:1px solid #e2e8f0; display:flex; flex-direction:column; gap:14px;">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:5px;">
Location Name <span style="color:#ef4444;">*</span>
</label>
<input id="loc-modal-name" type="text" class="form-input" style="width:100%; font-size:13px;" placeholder="e.g. Main Warehouse">
<p id="loc-modal-name-error" class="field-error"></p>
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:5px;">Address</label>
<div style="display:flex; gap:6px; align-items:stretch;">
<input id="loc-modal-address" type="text" class="form-input" style="flex:1; font-size:13px;" placeholder="Street, City, Country"
onkeydown="if(event.key==='Enter'){event.preventDefault();geocodeAddress();}">
<button type="button" onclick="geocodeAddress()" title="Search address on map"
style="flex-shrink:0; padding:0 11px; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:6px; cursor:pointer; color:#475569; display:flex; align-items:center;">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:5px;">Latitude</label>
<input id="loc-modal-lat" type="number" step="any" class="form-input"
style="width:100%; font-size:12px; font-family:monospace;" placeholder="25.2048"
oninput="onLatLngInput()">
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:5px;">Longitude</label>
<input id="loc-modal-lng" type="number" step="any" class="form-input"
style="width:100%; font-size:12px; font-family:monospace;" placeholder="55.2708"
oninput="onLatLngInput()">
</div>
</div>
<div style="background:#f0f9ff; border:1px solid #bae6fd; border-radius:8px; padding:9px 11px;">
<p style="font-size:11px; color:#0369a1; margin:0; line-height:1.6;">
<strong>Map tips:</strong><br>
Click anywhere on the map to place the pin<br>
Drag the pin to fine-tune position<br>
Type an address and press <strong></strong> or click 🔍 to find it
</p>
</div>
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; color:#374151;">
<input type="checkbox" id="loc-modal-active" checked style="width:14px; height:14px;">
Active
</label>
<p id="loc-modal-error" class="field-error"></p>
</div>
{{-- Right: Leaflet map --}}
<div style="flex:1; position:relative;">
<div id="loc-map"></div>
<div id="loc-map-hint" style="position:absolute; bottom:10px; left:50%; transform:translateX(-50%); background:rgba(15,23,42,0.72); color:white; font-size:11px; padding:4px 12px; border-radius:20px; pointer-events:none; white-space:nowrap; z-index:1000;">
Click map to place pin Drag pin to adjust
</div>
</div>
</div>
{{-- Footer --}}
<div style="display:flex; align-items:center; justify-content:flex-end; gap:10px; padding:0.875rem 1.5rem; border-top:1px solid #e2e8f0; flex-shrink:0;">
<button type="button" onclick="closeLocModal()" class="btn-secondary">Cancel</button>
<button type="button" onclick="saveLocModal()" class="btn-primary">Save Location</button>
</div>
</div>
</div>
{{-- ═══════════════════════════════════════════════════ --}}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV/XN/WLc=" crossorigin=""></script>
<script>
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 !!};
// ── Core fetch helper ────────────────────────────────────────────────────────
function api(url, method, data) {
var opts = { method: method, headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' } };
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
});
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');
function firstError(err) {
if (err && err.errors) { var k = Object.keys(err.errors); if (k.length) return err.errors[k[0]][0]; }
return err.message || 'Something went wrong.';
}
// ── 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) {
function esc(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,"\\'");
// ── Leaflet Map ───────────────────────────────────────────────────────────────
var locMap = null, locMarker = null;
var defaultCenter = [25.2048, 55.2708]; // UAE fallback; overridden by geolocation
var defaultZoom = 11;
// Attempt to get user's position for a better default center
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
defaultCenter = [pos.coords.latitude, pos.coords.longitude];
defaultZoom = 13;
// If map is already open with no pin, re-center
if (locMap && !locMarker) locMap.setView(defaultCenter, defaultZoom);
}, null, { timeout: 6000 });
}
// 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;
function initMap() {
if (locMap) return;
locMap = L.map('loc-map').setView(defaultCenter, defaultZoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(locMap);
locMap.on('click', function(e) {
setMapPin(e.latlng.lat, e.latlng.lng);
document.getElementById('loc-modal-lat').value = e.latlng.lat.toFixed(7);
document.getElementById('loc-modal-lng').value = e.latlng.lng.toFixed(7);
});
}
function setMapPin(lat, lng) {
var latlng = L.latLng(lat, lng);
if (locMarker) {
locMarker.setLatLng(latlng);
} else {
locMarker = L.marker(latlng, { draggable: true }).addTo(locMap);
locMarker.on('dragend', function(e) {
var pos = e.target.getLatLng();
document.getElementById('loc-modal-lat').value = pos.lat.toFixed(7);
document.getElementById('loc-modal-lng').value = pos.lng.toFixed(7);
});
}
if (autoId) {
// Wait for DOM
setTimeout(function() { selectProject(autoId); }, 0);
locMap.setView(latlng, Math.max(locMap.getZoom(), 14));
}
function removeMapPin() {
if (locMarker) { locMarker.remove(); locMarker = null; }
}
function onLatLngInput() {
var lat = parseFloat(document.getElementById('loc-modal-lat').value);
var lng = parseFloat(document.getElementById('loc-modal-lng').value);
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
setMapPin(lat, lng);
}
}
function geocodeAddress() {
var addr = document.getElementById('loc-modal-address').value.trim();
if (!addr) { showToast('Enter an address to search.', 'warn'); return; }
fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(addr) + '&limit=1', {
headers: { 'Accept-Language': 'en', 'User-Agent': 'OperationModule/1.0' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.length) {
var lat = parseFloat(data[0].lat), lng = parseFloat(data[0].lon);
document.getElementById('loc-modal-lat').value = lat.toFixed(7);
document.getElementById('loc-modal-lng').value = lng.toFixed(7);
setMapPin(lat, lng);
} else {
showToast('Address not found — try a more specific query.', 'warn');
}
})
.catch(function() { showToast('Geocoding failed. Check your connection.', 'error'); });
}
// ── Location Modal ────────────────────────────────────────────────────────────
var _locMode = 'add', _locId = null, _projId = null;
function openLocModal(locId, projectId) {
_locMode = locId ? 'edit' : 'add';
_locId = locId;
_projId = projectId;
var projNameEl = document.getElementById('proj-name-' + projectId);
var projName = projNameEl ? projNameEl.textContent.trim() : '';
document.getElementById('loc-modal-title').textContent =
locId ? 'Edit Location' : 'Add Location' + (projName ? ' — ' + projName : '');
var d = locId ? (LOC_DATA[locId] || {}) : {};
document.getElementById('loc-modal-name').value = d.name || '';
document.getElementById('loc-modal-address').value = d.address || '';
document.getElementById('loc-modal-lat').value = d.latitude != null ? d.latitude : '';
document.getElementById('loc-modal-lng').value = d.longitude != null ? d.longitude : '';
document.getElementById('loc-modal-active').checked = d.is_active !== false;
document.getElementById('loc-modal-name-error').style.display = 'none';
document.getElementById('loc-modal-error').style.display = 'none';
document.getElementById('loc-modal-overlay').classList.add('open');
// Init/refresh map after overlay becomes visible
setTimeout(function() {
initMap();
locMap.invalidateSize();
removeMapPin();
if (d.latitude != null && d.longitude != null) {
setMapPin(parseFloat(d.latitude), parseFloat(d.longitude));
} else {
locMap.setView(defaultCenter, defaultZoom);
}
}, 60);
}
function closeLocModal() {
document.getElementById('loc-modal-overlay').classList.remove('open');
}
function saveLocModal() {
var name = document.getElementById('loc-modal-name').value.trim();
var address = document.getElementById('loc-modal-address').value.trim() || null;
var latRaw = document.getElementById('loc-modal-lat').value.trim();
var lngRaw = document.getElementById('loc-modal-lng').value.trim();
var isActive = document.getElementById('loc-modal-active').checked;
var nameErr = document.getElementById('loc-modal-name-error');
var genErr = document.getElementById('loc-modal-error');
nameErr.style.display = 'none';
genErr.style.display = 'none';
if (!name) { nameErr.textContent = 'Location name is required.'; nameErr.style.display = 'block'; return; }
var payload = {
name: name,
address: address,
latitude: latRaw !== '' ? parseFloat(latRaw) : null,
longitude: lngRaw !== '' ? parseFloat(lngRaw) : null,
is_active: isActive ? 1 : 0
};
var url = BASE + '/' + _projId + '/locations' + (_locMode === 'edit' ? '/' + _locId : '');
var method = _locMode === 'edit' ? 'PATCH' : 'POST';
api(url, method, payload)
.then(function(data) {
var loc = data.location;
// Update global data store
LOC_DATA[loc.id] = loc;
closeLocModal();
if (_locMode === 'add') {
var emptyEl = document.getElementById('loc-empty-' + _projId);
if (emptyEl) emptyEl.remove();
var div = document.createElement('div');
div.innerHTML = buildLocationRow(_projId, loc);
insertLocInOrder(_projId, div.firstElementChild);
updateLocCount(_projId);
showToast('Location "' + esc(loc.name) + '" added.', 'success');
} else {
// Update display
document.getElementById('loc-name-' + loc.id).textContent = loc.name;
var badge = document.getElementById('loc-inactive-' + loc.id);
if (badge) badge.style.display = loc.is_active ? 'none' : '';
var icon = document.getElementById('loc-icon-' + loc.id);
if (icon) icon.setAttribute('stroke', loc.is_active ? '#22c55e' : '#9ca3af');
var addrEl = document.getElementById('loc-address-' + loc.id);
if (addrEl) { addrEl.textContent = loc.address || ''; addrEl.style.display = loc.address ? '' : 'none'; }
var gpsEl = document.getElementById('loc-gps-' + loc.id);
if (gpsEl) {
if (loc.latitude != null && loc.longitude != null) {
gpsEl.textContent = parseFloat(loc.latitude).toFixed(6) + '°, ' + parseFloat(loc.longitude).toFixed(6) + '°';
gpsEl.style.display = '';
} else {
gpsEl.style.display = 'none';
}
}
showToast('Location updated.', 'success');
}
})
.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');
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 })
.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);
list.appendChild(div.firstElementChild);
showToast('Project "' + esc(p.name) + '" added.', 'success');
})
.catch(function(e) { err.textContent = firstError(e); err.style.display = 'block'; });
}
function openEditProject(id, name, isActive) {
document.getElementById('proj-edit-' + id).classList.add('open');
document.getElementById('edit-proj-name-' + id).value = name;
document.getElementById('edit-proj-active-' + id).checked = isActive;
var errEl = document.getElementById('edit-proj-error-' + id);
if (errEl) errEl.style.display = 'none';
}
function closeEditProject(id) { document.getElementById('proj-edit-' + id).classList.remove('open'); }
function saveProject(id) {
var name = document.getElementById('edit-proj-name-' + id).value.trim();
var isActive = document.getElementById('edit-proj-active-' + id).checked;
var errEl = document.getElementById('edit-proj-error-' + id);
errEl.style.display = 'none';
if (!name) { errEl.textContent = 'Name is required.'; errEl.style.display = 'block'; return; }
api(BASE + '/' + id, 'PATCH', { name: name, is_active: isActive ? 1 : 0 })
.then(function(data) {
var p = data.project;
closeEditProject(id);
document.getElementById('proj-name-' + id).textContent = p.name;
var badge = document.getElementById('proj-inactive-badge-' + id);
if (badge) badge.style.display = p.is_active ? 'none' : '';
var icon = document.getElementById('proj-icon-' + id);
if (icon) icon.setAttribute('stroke', p.is_active ? '#2563eb' : '#9ca3af');
showToast('Project updated.', 'success');
})
.catch(function(e) { errEl.textContent = firstError(e); errEl.style.display = 'block'; });
}
function deleteProject(id, name) {
confirmAction('Delete Project', 'Delete "' + name + '" and all its locations?', 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 =
'<div id="no-projects-msg" class="card card-body" style="text-align:center;padding:3rem;color:#9ca3af;">No projects yet. Add your first project above.</div>';
}
showToast('Project "' + esc(name) + '" deleted.', 'success');
})
.catch(function(e) { showToast(firstError(e), 'error'); });
});
}
// ── Location Delete ───────────────────────────────────────────────────────────
function deleteLoc(projectId, locId, name) {
confirmAction('Delete Location', 'Delete "' + name + '"?', function() {
api(BASE + '/' + projectId + '/locations/' + locId, 'DELETE')
.then(function() {
delete LOC_DATA[locId];
var wrap = document.getElementById('loc-wrap-' + locId);
if (wrap) wrap.remove();
updateLocCount(projectId);
var list = document.getElementById('loc-list-' + projectId);
if (list && !list.querySelector('[id^="loc-wrap-"]')) {
list.innerHTML = '<div id="loc-empty-' + projectId + '" style="padding:14px 1.25rem 14px 2.5rem; color:#9ca3af; font-size:13px; border-bottom:1px solid #f1f5f9;">No locations yet — add the first one below.</div>';
}
showToast('Location "' + esc(name) + '" deleted.', 'success');
})
.catch(function(e) { showToast(firstError(e), 'error'); });
});
}
// ── Accordion toggle ─────────────────────────────────────────────────────────
function toggleProject(id) {
var body = document.getElementById('proj-body-' + id);
var chevron = document.getElementById('chevron-' + id);
body.classList.toggle('open');
chevron.classList.toggle('open');
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function updateLocCount(projectId) {
var list = document.getElementById('loc-list-' + projectId);
var count = list ? list.querySelectorAll('[id^="loc-wrap-"]').length : 0;
var el = document.getElementById('proj-loc-count-' + projectId);
if (el) el.textContent = count + ' ' + (count === 1 ? 'location' : 'locations');
}
function insertLocInOrder(projectId, newWrapEl) {
var list = document.getElementById('loc-list-' + projectId);
var newName = (newWrapEl.querySelector('[id^="loc-name-"]').textContent || '').trim().toLowerCase();
var wraps = list.querySelectorAll('[id^="loc-wrap-"]');
var inserted = false;
for (var i = 0; i < wraps.length; i++) {
var nameEl = wraps[i].querySelector('[id^="loc-name-"]');
if (nameEl && newName < nameEl.textContent.trim().toLowerCase()) {
list.insertBefore(newWrapEl, wraps[i]);
inserted = true;
break;
}
}
if (!inserted) list.appendChild(newWrapEl);
}
function buildProjectCard(p) {
var pName = esc(p.name);
var rawName = p.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return '<div class="proj-card" id="proj-card-' + p.id + '">'
+ '<div class="proj-header" onclick="toggleProject(' + p.id + ')">'
+ '<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0;">'
+ '<svg class="proj-chevron" id="chevron-' + p.id + '" width="14" height="14" fill="none" stroke="#94a3b8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'
+ '<svg width="15" height="15" fill="none" stroke="#2563eb" viewBox="0 0 24 24" style="flex-shrink:0;" id="proj-icon-' + p.id + '"><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 id="proj-name-' + p.id + '" style="font-size:14px;font-weight:600;color:#1e293b;">' + pName + '</span>'
+ '<span id="proj-inactive-badge-' + p.id + '" class="badge-inactive" style="display:none;">Inactive</span>'
+ '<span id="proj-loc-count-' + p.id + '" style="font-size:12px;color:#94a3b8;margin-left:2px;">0 locations</span>'
+ '</div>'
+ '<div style="display:flex;gap:6px;flex-shrink:0;" onclick="event.stopPropagation()">'
+ '<button type="button" onclick="openEditProject(' + p.id + ', \'' + rawName + '\', true)" class="btn-secondary btn-sm">Edit</button>'
+ '<button type="button" onclick="deleteProject(' + p.id + ', \'' + rawName + '\')" class="btn-danger btn-sm">Delete</button>'
+ '</div>'
+ '</div>'
+ '<div class="proj-edit-wrap" id="proj-edit-' + p.id + '">'
+ '<input id="edit-proj-name-' + p.id + '" type="text" 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="checkbox" id="edit-proj-active-' + p.id + '" checked style="width:14px;height:14px;"> Active</label>'
+ '<button type="button" onclick="saveProject(' + p.id + ')" class="btn-primary" style="padding:5px 14px;font-size:12px;white-space:nowrap;">Save</button>'
+ '<button type="button" onclick="closeEditProject(' + p.id + ')" class="btn-secondary btn-sm">Cancel</button>'
+ '<p id="edit-proj-error-' + p.id + '" class="field-error" style="margin:0;"></p>'
+ '</div>'
+ '<div class="proj-body" id="proj-body-' + p.id + '">'
+ '<div id="loc-list-' + p.id + '">'
+ '<div id="loc-empty-' + p.id + '" style="padding:14px 1.25rem 14px 2.5rem;color:#9ca3af;font-size:13px;border-bottom:1px solid #f1f5f9;">No locations yet — add the first one below.</div>'
+ '</div>'
+ '<div style="padding:0.875rem 1.25rem;background:#f8fafc;border-top:1px solid #e2e8f0;">'
+ '<button type="button" onclick="openLocModal(null, ' + p.id + ')" class="btn-primary" style="padding:0.4rem 1rem;font-size:13px;">+ Add Location to ' + pName + '</button>'
+ '</div>'
+ '</div>'
+ '</div>';
}
function buildLocationRow(projectId, loc) {
var safeName = (loc.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
var addrHtml = loc.address
? '<div id="loc-address-' + loc.id + '" style="font-size:12px;color:#64748b;margin-top:2px;">' + esc(loc.address) + '</div>'
: '<div id="loc-address-' + loc.id + '" style="font-size:12px;color:#64748b;margin-top:2px;display:none;"></div>';
var gpsHtml = (loc.latitude != null && loc.longitude != null)
? '<div id="loc-gps-' + loc.id + '" style="font-size:11px;color:#94a3b8;margin-top:1px;font-family:monospace;">' + parseFloat(loc.latitude).toFixed(6) + '°, ' + parseFloat(loc.longitude).toFixed(6) + '°</div>'
: '<div id="loc-gps-' + loc.id + '" style="font-size:11px;color:#94a3b8;margin-top:1px;font-family:monospace;display:none;"></div>';
return '<div id="loc-wrap-' + loc.id + '">'
+ '<div class="loc-row" id="loc-row-' + loc.id + '">'
+ '<div style="display:flex;align-items:flex-start;gap:10px;flex:1;min-width:0;">'
+ '<svg width="14" height="14" fill="none" stroke="#22c55e" viewBox="0 0 24 24" id="loc-icon-' + loc.id + '" style="flex-shrink:0;margin-top:3px;"><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"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>'
+ '<div style="flex:1;min-width:0;">'
+ '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;"><span id="loc-name-' + loc.id + '" style="font-size:13px;font-weight:600;color:#1e293b;">' + esc(loc.name) + '</span><span id="loc-inactive-' + loc.id + '" class="badge-inactive" style="display:none;">Inactive</span></div>'
+ addrHtml + gpsHtml
+ '</div>'
+ '</div>'
+ '<div style="display:flex;gap:4px;flex-shrink:0;margin-top:1px;">'
+ '<button type="button" onclick="openLocModal(' + loc.id + ', ' + projectId + ')" class="btn-secondary btn-sm" style="padding:3px 8px;font-size:12px;">Edit</button>'
+ '<button type="button" onclick="deleteLoc(' + projectId + ', ' + loc.id + ', \'' + safeName + '\')" class="btn-danger btn-sm" style="padding:3px 8px;font-size:12px;">Delete</button>'
+ '</div>'
+ '</div>'
+ '</div>';
}
// Auto-expand first project on load
(function() {
var first = document.querySelector('.proj-card');
if (first) {
var id = parseInt(first.id.replace('proj-card-', ''), 10);
document.getElementById('proj-body-' + id).classList.add('open');
document.getElementById('chevron-' + id).classList.add('open');
}
})();
// 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