From dca9cd5d993ff4295307bbafa26d61f33b996866 Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Mon, 1 Jun 2026 11:52:21 +0300 Subject: [PATCH] feat: RFQ portal, notifications, and project settings updates Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Purchase/RfqController.php | 19 + .../Purchase/RfqPortalController.php | 32 +- .../Settings/ProjectSettingController.php | 37 +- app/Models/User.php | 5 +- app/Notifications/QuoteReceived.php | 32 ++ app/Services/ProjectImportService.php | 44 +- ...6_01_072715_create_notifications_table.php | 31 ++ .../purchase/edit-request-modal.blade.php | 147 ++++++- .../purchase/request-modal.blade.php | 391 ++++++++++++++++-- .../purchase/supplier-select-modal.blade.php | 145 ++++--- resources/views/layouts/app.blade.php | 103 +++++ resources/views/rfq/show.blade.php | 140 ++++++- resources/views/rfq/submitted.blade.php | 88 +++- .../views/settings/projects/index.blade.php | 7 +- routes/web.php | 14 + 15 files changed, 1095 insertions(+), 140 deletions(-) create mode 100644 app/Notifications/QuoteReceived.php create mode 100644 database/migrations/2026_06_01_072715_create_notifications_table.php diff --git a/app/Http/Controllers/Purchase/RfqController.php b/app/Http/Controllers/Purchase/RfqController.php index 8c3537b..80495bd 100644 --- a/app/Http/Controllers/Purchase/RfqController.php +++ b/app/Http/Controllers/Purchase/RfqController.php @@ -36,6 +36,9 @@ class RfqController extends Controller } if (empty($supplierItems)) { + if ($request->expectsJson()) { + return response()->json(['message' => 'Please assign at least one supplier to an item.'], 422); + } return redirect()->back()->with('error', 'Please assign at least one supplier to an item.'); } @@ -67,6 +70,22 @@ class RfqController extends Controller $stages->setStage($purchaseRequest, 'rfq'); + if ($request->expectsJson()) { + $invitations = $purchaseRequest->rfqInvitations()->with('supplier')->get()->map(function ($inv) { + return [ + 'supplier_name' => $inv->supplier->name, + 'channel' => $inv->channel, + 'url' => route('rfq.show', $inv->token), + 'status' => $inv->status, + ]; + }); + return response()->json([ + 'added' => $added, + 'invitations' => $invitations, + 'redirect' => route('purchase.pipeline.show', $purchaseRequest), + ]); + } + return redirect()->route('purchase.pipeline.show', $purchaseRequest) ->with('success', $added . ' supplier(s) added. Now send them the quote request links.'); } diff --git a/app/Http/Controllers/Purchase/RfqPortalController.php b/app/Http/Controllers/Purchase/RfqPortalController.php index 98a265c..b519901 100644 --- a/app/Http/Controllers/Purchase/RfqPortalController.php +++ b/app/Http/Controllers/Purchase/RfqPortalController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Models\RfqInvitation; use App\Models\SupplierQuote; use App\Models\SupplierQuoteItem; +use App\Models\User; +use App\Notifications\QuoteReceived; use App\Services\PurchaseStageService; use Illuminate\Http\Request; @@ -35,9 +37,16 @@ class RfqPortalController extends Controller } $purchaseRequest = $invitation->purchaseRequest; - $items = $purchaseRequest->items; + $itemIds = $invitation->item_ids; + $items = $itemIds + ? $purchaseRequest->items->whereIn('id', $itemIds)->values() + : $purchaseRequest->items; - return view('rfq.show', compact('invitation', 'purchaseRequest', 'items')); + // Generate a fresh confirmation code per page load and store in session + $confirmCode = strtoupper(substr(bin2hex(random_bytes(3)), 0, 5)); + session(['rfq_confirm_' . $token => $confirmCode]); + + return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode')); } public function submit(Request $request, string $token) @@ -49,6 +58,8 @@ class RfqPortalController extends Controller } $validated = $request->validate([ + 'terms' => ['accepted'], + 'confirm_code' => ['required', 'string'], 'lead_time_days' => ['nullable', 'integer', 'min:0'], 'payment_terms' => ['nullable', 'string', 'max:200'], 'notes' => ['nullable', 'string', 'max:1000'], @@ -56,7 +67,16 @@ class RfqPortalController extends Controller 'items.*.unit_price' => ['required', 'numeric', 'min:0'], ]); - $purchaseItems = $invitation->purchaseRequest->items; + $expectedCode = session('rfq_confirm_' . $token); + if (!$expectedCode || strtoupper(trim($validated['confirm_code'])) !== $expectedCode) { + return back()->withErrors(['confirm_code' => 'Incorrect confirmation code. Please copy the code exactly as shown.'])->withInput(); + } + session()->forget('rfq_confirm_' . $token); + + $itemIds = $invitation->item_ids; + $purchaseItems = $itemIds + ? $invitation->purchaseRequest->items->whereIn('id', $itemIds)->values() + : $invitation->purchaseRequest->items; $quote = SupplierQuote::create([ 'rfq_invitation_id' => $invitation->id, @@ -72,7 +92,7 @@ class RfqPortalController extends Controller $total = 0; foreach ($purchaseItems as $i => $item) { $unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0); - $qty = (float)$item->quantity; + $qty = (float)$item->quantity_required; $totalPrice = round($unitPrice * $qty, 3); $total += $totalPrice; @@ -95,6 +115,10 @@ class RfqPortalController extends Controller app(PurchaseStageService::class)->setStage($pr, 'comparison'); } + // Notify all admin users + $invitation->load('supplier', 'purchaseRequest'); + User::role('Admin')->each(fn($u) => $u->notify(new QuoteReceived($invitation))); + return view('rfq.submitted', compact('invitation')); } } diff --git a/app/Http/Controllers/Settings/ProjectSettingController.php b/app/Http/Controllers/Settings/ProjectSettingController.php index f1e7698..66f3271 100644 --- a/app/Http/Controllers/Settings/ProjectSettingController.php +++ b/app/Http/Controllers/Settings/ProjectSettingController.php @@ -187,6 +187,7 @@ class ProjectSettingController extends Controller $parts = []; if ($stats['projects_created']) $parts[] = "{$stats['projects_created']} project(s)"; + if ($stats['locations_created']) $parts[] = "{$stats['locations_created']} location(s)"; if ($stats['departments_created']) $parts[] = "{$stats['departments_created']} department(s)"; if ($stats['companies_created']) $parts[] = "{$stats['companies_created']} new company(s)"; @@ -220,29 +221,41 @@ class ProjectSettingController extends Controller 'font' => ['italic' => true, 'color' => ['rgb' => '64748b'], 'size' => 10], ]; - // ── Sheet 1: Projects ────────────────────────────────────────────── + // ── Sheet 1: Projects (with optional locations) ──────────────────── $s1 = $spreadsheet->getActiveSheet()->setTitle('Projects'); $s1->setCellValue('A1', 'Company Name') - ->setCellValue('B1', 'Project Name'); - $s1->getStyle('A1:B1')->applyFromArray($headerStyle); + ->setCellValue('B1', 'Project Name') + ->setCellValue('C1', 'Location Name') + ->setCellValue('D1', 'Address') + ->setCellValue('E1', 'Latitude') + ->setCellValue('F1', 'Longitude'); + $s1->getStyle('A1:F1')->applyFromArray($headerStyle); - // Sample rows $samples = [ - ['Miknas Industrial', 'New Warehouse'], - ['Steel tech', 'Factory Extension'], - ['Steel tech', 'New Office Block'], + ['Miknas Industrial', 'New Warehouse', 'Main Gate', 'Industrial Area, Block 5', '24.7136', '46.6753'], + ['Miknas Industrial', 'New Warehouse', 'Storage Yard', '', '', ''], + ['Steel tech', 'Factory Extension','Site Office', '2nd Ring Road, Riyadh', '24.6877', '46.7219'], + ['Steel tech', 'New Office Block', '', '', '', ''], ]; foreach ($samples as $i => $row) { $s1->setCellValue('A' . ($i + 2), $row[0]); $s1->setCellValue('B' . ($i + 2), $row[1]); + $s1->setCellValue('C' . ($i + 2), $row[2]); + $s1->setCellValue('D' . ($i + 2), $row[3]); + $s1->setCellValue('E' . ($i + 2), $row[4]); + $s1->setCellValue('F' . ($i + 2), $row[5]); } - $s1->setCellValue('A6', '* Delete sample rows before importing. Company will be created if it does not exist.'); - $s1->getStyle('A6')->applyFromArray($noteStyle); - $s1->mergeCells('A6:B6'); + $s1->setCellValue('A7', '* Leave Location Name blank for rows that are projects only. Company is created automatically. Duplicates are skipped.'); + $s1->getStyle('A7')->applyFromArray($noteStyle); + $s1->mergeCells('A7:F7'); - $s1->getColumnDimension('A')->setWidth(32); - $s1->getColumnDimension('B')->setWidth(32); + $s1->getColumnDimension('A')->setWidth(28); + $s1->getColumnDimension('B')->setWidth(28); + $s1->getColumnDimension('C')->setWidth(24); + $s1->getColumnDimension('D')->setWidth(32); + $s1->getColumnDimension('E')->setWidth(14); + $s1->getColumnDimension('F')->setWidth(14); // ── Sheet 2: Departments ─────────────────────────────────────────── $s2 = new Worksheet($spreadsheet, 'Departments'); diff --git a/app/Models/User.php b/app/Models/User.php index 368f00c..127ee77 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -41,8 +41,11 @@ class User extends Authenticatable * * @return array */ - public function routeNotificationFor(string $channel, mixed $notification = null): ?string + public function routeNotificationFor(string $channel, mixed $notification = null): mixed { + if ($channel === 'database') { + return $this->notifications(); + } return $this->whatsapp_number; } diff --git a/app/Notifications/QuoteReceived.php b/app/Notifications/QuoteReceived.php new file mode 100644 index 0000000..f22b674 --- /dev/null +++ b/app/Notifications/QuoteReceived.php @@ -0,0 +1,32 @@ +invitation->purchaseRequest; + return [ + 'type' => 'quote_received', + 'message' => $this->invitation->supplier->name . ' submitted a quote for ' . $pr->request_number, + 'supplier_name' => $this->invitation->supplier->name, + 'request_number' => $pr->request_number, + 'purchase_request_id' => $pr->id, + 'url' => route('purchase.pipeline.show', $pr), + ]; + } +} diff --git a/app/Services/ProjectImportService.php b/app/Services/ProjectImportService.php index 7b774c8..52eae81 100644 --- a/app/Services/ProjectImportService.php +++ b/app/Services/ProjectImportService.php @@ -12,6 +12,7 @@ class ProjectImportService private array $stats = [ 'companies_created' => 0, 'projects_created' => 0, + 'locations_created' => 0, 'departments_created' => 0, 'skipped' => 0, ]; @@ -56,8 +57,12 @@ class ProjectImportService $rows = $sheet->toArray(null, true, true, false); $headers = $this->normalizeHeaders((array) array_shift($rows)); - $coIdx = $this->findCol($headers, ['company', 'company name', 'company name', 'companyname']); - $projIdx = $this->findCol($headers, ['project', 'project name', 'project name', 'projectname']); + $coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']); + $projIdx = $this->findCol($headers, ['project', 'project name', 'projectname']); + $locIdx = $this->findCol($headers, ['location', 'location name', 'locationname', 'loc', 'loc name']); + $addrIdx = $this->findCol($headers, ['address', 'addr']); + $latIdx = $this->findCol($headers, ['latitude', 'lat']); + $lngIdx = $this->findCol($headers, ['longitude', 'lng', 'lon', 'long']); if ($coIdx === null || $projIdx === null) { return; @@ -72,17 +77,38 @@ class ProjectImportService continue; } - $company = $this->findOrCreateCompany($coName); - $existing = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)]) + $company = $this->findOrCreateCompany($coName); + $project = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)]) ->where('company_id', $company->id)->first(); - if ($existing) { - $this->stats['skipped']++; - continue; + if (!$project) { + $project = ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]); + $this->stats['projects_created']++; } - ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]); - $this->stats['projects_created']++; + // If a location name is present on this row, import it too + $locName = $locIdx !== null ? $this->str($row[$locIdx] ?? null) : null; + if ($locName) { + $existingLoc = $project->locations() + ->whereRaw('LOWER(name) = ?', [strtolower($locName)])->first(); + + if ($existingLoc) { + $this->stats['skipped']++; + } else { + $address = $addrIdx !== null ? $this->str($row[$addrIdx] ?? null) : null; + $lat = $latIdx !== null ? $this->str($row[$latIdx] ?? null) : null; + $lng = $lngIdx !== null ? $this->str($row[$lngIdx] ?? null) : null; + + $project->locations()->create([ + 'name' => $locName, + 'address' => $address, + 'latitude' => is_numeric($lat) ? (float) $lat : null, + 'longitude' => is_numeric($lng) ? (float) $lng : null, + 'is_active' => true, + ]); + $this->stats['locations_created']++; + } + } } } diff --git a/database/migrations/2026_06_01_072715_create_notifications_table.php b/database/migrations/2026_06_01_072715_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_06_01_072715_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/components/purchase/edit-request-modal.blade.php b/resources/views/components/purchase/edit-request-modal.blade.php index e5719d9..9e3c374 100644 --- a/resources/views/components/purchase/edit-request-modal.blade.php +++ b/resources/views/components/purchase/edit-request-modal.blade.php @@ -41,6 +41,10 @@ $editProjectsJson = json_encode($editProjectsData); $curProjectInList = $editProjects->contains('name', $curProject); @endphp + + {{-- ── Trigger button ── --}} + {{-- Panel --}} +
@@ -315,6 +377,87 @@ $curProjectInList = $editProjects->contains('name', $curProject); } window.{{ $prefix }}FilterLoc = {{ $prefix }}FilterLoc; + // ── Urgency colored dropdown ────────────────────────────────────────────── + var _urgencyMap{{ $prId }} = { + 'Urgent': ['Urgent', '#dc2626','#fef2f2'], + '3 Days': ['3 Days', '#ea580c','#fff7ed'], + '1 Week': ['1 Week', '#d97706','#fffbeb'], + '2 Weeks': ['2 Weeks', '#2563eb','#eff6ff'], + '1 Month': ['1 Month', '#16a34a','#f0fdf4'], + }; + + function _urgencyBadge{{ $prId }}(label, color, bgColor) { + return '' + + '' + + '' + label + ''; + } + + function _urgencyDateBadge{{ $prId }}(label) { + return '' + + '' + + '' + label + ''; + } + + window.{{ $prefix }}UrgencyToggle = function() { + var p = document.getElementById('{{ $prefix }}UrgencyPanel'); + p.style.display = p.style.display === 'none' ? 'block' : 'none'; + }; + + window.{{ $prefix }}UrgencyClose = function() { + document.getElementById('{{ $prefix }}UrgencyPanel').style.display = 'none'; + }; + + window.{{ $prefix }}UrgencySelect = function(value, label, color, bgColor) { + document.getElementById('{{ $prefix }}UrgencyValue').value = value; + document.getElementById('{{ $prefix }}UrgencyDisplay').innerHTML = _urgencyBadge{{ $prId }}(label, color, bgColor); + var t = document.getElementById('{{ $prefix }}UrgencyTrigger'); + t.style.borderColor = color; t.style.borderStyle = 'solid'; + document.getElementById('{{ $prefix }}UrgencyDatePickerRow').style.display = 'none'; + {{ $prefix }}UrgencyClose(); + }; + + window.{{ $prefix }}UrgencyDateOptClick = function() { + var row = document.getElementById('{{ $prefix }}UrgencyDatePickerRow'); + row.style.display = row.style.display === 'none' ? '' : 'none'; + if (row.style.display !== 'none') document.getElementById('{{ $prefix }}UrgencyDate').focus(); + }; + + window.{{ $prefix }}UrgencyDateChange = function(val) { + if (!val) return; + document.getElementById('{{ $prefix }}UrgencyValue').value = val; + var d = new Date(val + 'T00:00:00'); + var label = d.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'}); + document.getElementById('{{ $prefix }}UrgencyDisplay').innerHTML = _urgencyDateBadge{{ $prId }}(label); + var t = document.getElementById('{{ $prefix }}UrgencyTrigger'); + t.style.borderColor = '#7c3aed'; t.style.borderStyle = 'solid'; + {{ $prefix }}UrgencyClose(); + }; + + // Close on outside click + document.addEventListener('click', function(e) { + var w = document.getElementById('{{ $prefix }}UrgencyWrapper'); + if (w && !w.contains(e.target)) {{ $prefix }}UrgencyClose(); + }); + + // Restore saved value on load + document.addEventListener('DOMContentLoaded', function() { + var val = document.getElementById('{{ $prefix }}UrgencyValue').value; + if (!val) return; + if (_urgencyMap{{ $prId }}[val]) { + var m = _urgencyMap{{ $prId }}[val]; + {{ $prefix }}UrgencySelect(val, m[0], m[1], m[2]); + } else { + document.getElementById('{{ $prefix }}UrgencyDatePickerRow').style.display = ''; + document.getElementById('{{ $prefix }}UrgencyDate').value = val; + var d2 = new Date(val + 'T00:00:00'); + var lbl = d2.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'}); + document.getElementById('{{ $prefix }}UrgencyDisplay').innerHTML = _urgencyDateBadge{{ $prId }}(lbl); + var t2 = document.getElementById('{{ $prefix }}UrgencyTrigger'); + t2.style.borderColor = '#7c3aed'; t2.style.borderStyle = 'solid'; + } + }); + + // ── Cascading project → location dropdown ───────────────────────────────── // On load: populate locations and pre-select the current ones document.addEventListener('DOMContentLoaded', function () { var curProj = document.getElementById('{{ $prefix }}Project').value; diff --git a/resources/views/components/purchase/request-modal.blade.php b/resources/views/components/purchase/request-modal.blade.php index 11bac9a..0713aaf 100644 --- a/resources/views/components/purchase/request-modal.blade.php +++ b/resources/views/components/purchase/request-modal.blade.php @@ -1,20 +1,38 @@ @php $hasErrors = $errors->any(); $mprProjects = \App\Models\Settings\ProjectSetting::active() - ->with(['locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); }]) + ->whereNotNull('company_id') + ->with([ + 'company', + '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) { + 'id' => $p->id, + 'name' => $p->name, + 'company_id' => $p->company_id, + 'company_name' => $p->company ? $p->company->name : '', + 'label' => ($p->company ? $p->company->name . ' — ' : '') . $p->name, + 'locations' => $p->locations->map(function ($l) { return ['name' => $l->name]; })->values(), ]; })->values(); + +$mprDepartments = \App\Models\Settings\Department::where('is_active', true)->orderBy('name')->get(); +$mprDepartmentsJson = $mprDepartments->map(fn($d) => ['id' => $d->id, 'name' => $d->name, 'company_id' => $d->company_id])->values(); + +$mprUnits = ['PCS','NOS','KG','TON','MTR','SQM','LTR','BAG','BOX','ROLL','SET','EA','CANS','LOT']; +$today = date('Y-m-d'); @endphp + + {{-- Trigger button --}} + {{-- Dropdown panel --}} +
-
+
- + + {{-- Trigger --}} + + {{-- Panel --}} +
@@ -99,7 +232,12 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
- +
@@ -119,7 +257,7 @@ $mprProjectsJson = $mprProjects->map(function ($p) { # Description * - Unit + Unit Qty * Purpose Req. Date @@ -136,8 +274,12 @@ $mprProjectsJson = $mprProjects->map(function ($p) { style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required> - + map(function ($p) { style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…"> - @@ -204,6 +346,13 @@ document.addEventListener('keydown', function (e) { document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); }); @endif +// Unit options HTML (shared between template rows and JS-added rows) +var mprUnitOptions = '' + + @foreach($mprUnits as $u) + '' + + @endforeach + ''; + // Dynamic rows (function () { var mprRowIndex = {{ count($oldItems ?? [[]]) }}; @@ -214,18 +363,26 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); }); }); } + function mprLastRowDate() { + var rows = document.querySelectorAll('#mpr-items-body .mpr-item-row'); + if (!rows.length) return '{{ $today }}'; + var lastDate = rows[rows.length - 1].querySelector('input[type="date"]'); + return (lastDate && lastDate.value) ? lastDate.value : '{{ $today }}'; + } + function mprNewRow() { - var idx = mprRowIndex++; - var tr = document.createElement('tr'); + var idx = mprRowIndex++; + var date = mprLastRowDate(); + var tr = document.createElement('tr'); tr.className = 'mpr-item-row'; tr.style.background = 'white'; tr.innerHTML = '' + '' + - '' + + '' + '' + '' + - '' + + '' + ''; return tr; } @@ -253,11 +410,61 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); }); }); })(); -// Cascading project → location dropdown -var mprProjectsData = @json($mprProjectsJson); -var mprOldLocation = "{{ old('location') }}"; -var mprOldProjectName = "{{ old('project_name') }}"; +// ── Cascading data ──────────────────────────────────────────────────────────── +var mprProjectsData = @json($mprProjectsJson); +var mprDepartmentsData = @json($mprDepartmentsJson); +var mprOldLocation = "{{ old('location') }}"; +var mprOldProjectName = "{{ old('project_name') }}"; +var mprOldDepartment = "{{ old('department') }}"; +// ── Searchable project dropdown ─────────────────────────────────────────────── +function mprProjectToggle() { + var panel = document.getElementById('mpr-project-panel'); + var open = panel.style.display !== 'none'; + if (open) { + mprProjectClose(); + } else { + panel.style.display = 'block'; + var search = document.getElementById('mpr-project-search'); + search.value = ''; + mprProjectSearch(''); + setTimeout(function(){ search.focus(); }, 30); + } +} + +function mprProjectClose() { + document.getElementById('mpr-project-panel').style.display = 'none'; +} + +function mprProjectSearch(q) { + var items = document.querySelectorAll('#mpr-project-list .mpr-proj-opt'); + var empty = document.getElementById('mpr-proj-empty'); + var term = q.trim().toLowerCase(); + var visible = 0; + items.forEach(function(li) { + var match = !term || li.dataset.search.indexOf(term) !== -1; + li.style.display = match ? '' : 'none'; + if (match) visible++; + }); + empty.style.display = visible === 0 ? '' : 'none'; +} + +function mprProjectSelect(value, label) { + document.getElementById('mpr-project-value').value = value; + var lbl = document.getElementById('mpr-project-label'); + lbl.textContent = label; + lbl.style.color = '#111827'; + mprProjectClose(); + mprOnProjectChange(value); +} + +// Close panel when clicking outside +document.addEventListener('click', function(e) { + var wrapper = document.getElementById('mpr-project-wrapper'); + if (wrapper && !wrapper.contains(e.target)) mprProjectClose(); +}); + +// ── Location cascade ────────────────────────────────────────────────────────── function mprFilterLocations(projectName) { var sel = document.getElementById('mpr-location-select'); sel.innerHTML = ''; @@ -277,12 +484,138 @@ function mprFilterLocations(projectName) { } } -// On load: if old project value exists (validation error repopulation), trigger filter +// ── Department cascade ──────────────────────────────────────────────────────── +function mprFilterDepartments(projectName) { + var sel = document.getElementById('mpr-department-select'); + sel.innerHTML = ''; + var companyId = null; + if (projectName) { + var proj = mprProjectsData.find(function(p){ return p.name === projectName; }); + if (proj) companyId = proj.company_id; + } + var depts = companyId + ? mprDepartmentsData.filter(function(d){ return d.company_id === companyId; }) + : mprDepartmentsData; + depts.forEach(function(d){ + var opt = document.createElement('option'); + opt.value = d.name; + opt.textContent = d.name; + if (d.name === mprOldDepartment) opt.selected = true; + sel.appendChild(opt); + }); + sel.disabled = depts.length === 0; +} + +function mprOnProjectChange(projectName) { + mprFilterLocations(projectName); + mprFilterDepartments(projectName); +} + +// ── Restore old values on validation error repopulation ─────────────────────── document.addEventListener('DOMContentLoaded', function(){ if (mprOldProjectName) { - mprFilterLocations(mprOldProjectName); + var proj = mprProjectsData.find(function(p){ return p.name === mprOldProjectName; }); + if (proj) mprProjectSelect(proj.name, proj.label); } else { document.getElementById('mpr-location-select').disabled = true; + mprFilterDepartments(''); } + // Restore urgency pill + var oldUrgency = document.getElementById('mpr-urgency-value').value; + if (oldUrgency) mprUrgencyRestore(oldUrgency); }); + +// ── Urgency colored dropdown ────────────────────────────────────────────────── +var mprUrgencyPresets = ['Urgent','3 Days','1 Week','2 Weeks','1 Month']; + +function mprUrgencyToggle() { + var panel = document.getElementById('mpr-urgency-panel'); + if (panel.style.display === 'none') { + panel.style.display = 'block'; + } else { + mprUrgencyClose(); + } +} + +function mprUrgencyClose() { + document.getElementById('mpr-urgency-panel').style.display = 'none'; +} + +function mprUrgencySelect(value, label, color, bgColor) { + document.getElementById('mpr-urgency-value').value = value; + // Update trigger display + var display = document.getElementById('mpr-urgency-display'); + display.innerHTML = + '' + + '' + + '' + label + '' + + ''; + // Update trigger border color + document.getElementById('mpr-urgency-trigger').style.borderColor = color; + document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid'; + // Hide date picker row + document.getElementById('mpr-urgency-datepicker-row').style.display = 'none'; + mprUrgencyClose(); +} + +function mprUrgencyDateOptClick() { + var row = document.getElementById('mpr-urgency-datepicker-row'); + row.style.display = row.style.display === 'none' ? '' : 'none'; + if (row.style.display !== 'none') { + document.getElementById('mpr-urgency-date').focus(); + } +} + +function mprUrgencyDateChange(val) { + if (!val) return; + document.getElementById('mpr-urgency-value').value = val; + // Format nicely for display + var d = new Date(val + 'T00:00:00'); + var label = d.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'}); + var display = document.getElementById('mpr-urgency-display'); + display.innerHTML = + '' + + '' + + '' + label + '' + + ''; + document.getElementById('mpr-urgency-trigger').style.borderColor = '#7c3aed'; + document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid'; + mprUrgencyClose(); +} + +// Close on outside click +document.addEventListener('click', function(e) { + var w = document.getElementById('mpr-urgency-wrapper'); + if (w && !w.contains(e.target)) mprUrgencyClose(); +}); + +// Restore on validation error repopulation +function mprUrgencyRestore(val) { + if (!val) return; + var presets = { + 'Urgent': ['Urgent', '#dc2626','#fef2f2'], + '3 Days': ['3 Days', '#ea580c','#fff7ed'], + '1 Week': ['1 Week', '#d97706','#fffbeb'], + '2 Weeks': ['2 Weeks', '#2563eb','#eff6ff'], + '1 Month': ['1 Month', '#16a34a','#f0fdf4'], + }; + if (presets[val]) { + mprUrgencySelect(val, presets[val][0], presets[val][1], presets[val][2]); + } else { + // It's a specific date string + document.getElementById('mpr-urgency-value').value = val; + document.getElementById('mpr-urgency-datepicker-row').style.display = ''; + document.getElementById('mpr-urgency-date').value = val; + var d = new Date(val + 'T00:00:00'); + var label = d.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'}); + var display = document.getElementById('mpr-urgency-display'); + display.innerHTML = + '' + + '' + + '' + label + '' + + ''; + document.getElementById('mpr-urgency-trigger').style.borderColor = '#7c3aed'; + document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid'; + } +} diff --git a/resources/views/components/purchase/supplier-select-modal.blade.php b/resources/views/components/purchase/supplier-select-modal.blade.php index c223bf7..2fdc1e7 100644 --- a/resources/views/components/purchase/supplier-select-modal.blade.php +++ b/resources/views/components/purchase/supplier-select-modal.blade.php @@ -256,18 +256,15 @@ {{-- /sup-step2 --}} - {{-- Step 3: Summary / confirmation (By Item only) --}} + {{-- Step 3: Links --}} @@ -520,69 +517,117 @@ function updateFooter() { } } +var _rfqRedirect = null; + 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(); } + + // Show step 3 with loading state immediately + document.getElementById('sup-summary-body').innerHTML = + '
Generating links…
'; + document.getElementById('sup-modal-title').textContent = 'Quote Links'; + document.getElementById('sup-modal-subtitle').textContent = 'One-time-use links for each supplier'; + document.getElementById('sup-step2').style.display = 'none'; + document.getElementById('sup-step3').style.display = 'flex'; + + var form = document.getElementById('sup-form'); + var CSRF = document.querySelector('meta[name="csrf-token"]').content; + fetch(form.action, { + method: 'POST', + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF }, + body: new FormData(form), + }).then(function(r) { + return r.json().then(function(body) { + if (!r.ok) return Promise.reject(body); + return body; + }); + }).then(function(data) { + _rfqRedirect = data.redirect || null; + showLinks(data.invitations || []); + }).catch(function(err) { + document.getElementById('sup-step3').style.display = 'none'; + document.getElementById('sup-step2').style.display = 'flex'; + document.getElementById('sup-modal-title').textContent = 'Select Suppliers'; + document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request'; + showToast((err && err.message) || 'Something went wrong.', 'error'); + }); } function escHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } -function showSummary() { - var chanLabel = { - email: 'Email', - whatsapp: 'WhatsApp', - both: 'Email + WA', - }; - var html = '
Review assignments before sending
'; +var _chanBadge = { + email: 'Email', + whatsapp: 'WhatsApp', + both: 'Email + WA', +}; - 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 += '
'; - html += '
'; - html += '
' + escHtml(name) + '
'; - if (qty) html += '
Qty: ' + escHtml(qty) + '
'; - html += '
'; - - 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] || '' + escHtml(chan) + ''; - html += '
'; - html += '' + escHtml(supName) + ''; - html += cl; +function showLinks(invitations) { + var html = ''; + if (!invitations || invitations.length === 0) { + html = '
No new invitations were created.
'; + } else { + html += '
Each link is private to the supplier and valid until submitted or expired.
'; + invitations.forEach(function(inv, i) { + var badge = _chanBadge[inv.channel] || ''; + html += '
'; + html += '
'; + html += '' + escHtml(inv.supplier_name) + ''; + html += badge; + html += '
'; + html += '
'; + html += '' + + '' + + '' + + '' + + 'Open Quote Link' + + ''; + html += ''; + html += '
'; html += '
'; }); - - html += '
'; - }); + } 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'; + document.getElementById('sup-modal-title').textContent = 'Quote Links Ready'; + document.getElementById('sup-modal-subtitle').textContent = 'Share with suppliers via their preferred channel'; + var countEl = document.getElementById('sup-link-count'); + if (countEl) countEl.textContent = invitations.length + ' link' + (invitations.length === 1 ? '' : 's') + ' generated'; } -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'; +function copyLink(btn) { + var url = btn.dataset.url; + if (!url) return; + navigator.clipboard.writeText(url).then(function() { + btn.textContent = 'Copied!'; + btn.style.background = '#16a34a'; + setTimeout(function() { btn.textContent = 'Copy'; btn.style.background = '#2563eb'; }, 2000); + }).catch(function() { + // Fallback for older browsers + var ta = document.createElement('textarea'); + ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); btn.textContent = 'Copied!'; btn.style.background = '#16a34a'; + setTimeout(function() { btn.textContent = 'Copy'; btn.style.background = '#2563eb'; }, 2000); + } catch(e) { showToast('Could not copy — please copy manually.', 'warn'); } + document.body.removeChild(ta); + }); +} + +function doneWithLinks() { + if (_rfqRedirect) { window.location.href = _rfqRedirect; } + else { window.location.reload(); } } diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 249f053..9052e5e 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -249,6 +249,38 @@
{{ now()->format('l, d M Y') }} +
+ + {{-- Bell notification --}} +
+ + + {{-- Dropdown --}} + +
+
@@ -493,6 +525,77 @@ document.addEventListener('keydown', function(e) { }); + + diff --git a/resources/views/rfq/submitted.blade.php b/resources/views/rfq/submitted.blade.php index f28cafb..d4c10cb 100644 --- a/resources/views/rfq/submitted.blade.php +++ b/resources/views/rfq/submitted.blade.php @@ -3,25 +3,89 @@ - Quote Submitted + Quote Submitted — Thank You -
-
-
Quote Submitted!
-
- Thank you, {{ $invitation->supplier->name }}. Your quote for - {{ $invitation->purchaseRequest->request_number }} has been received - and is under review. You will be contacted if you are selected. +
+ +
+ +
+ + {{-- Animated check --}} +
+
+
+ + + +
-
- Submitted on {{ now()->format('d M Y, H:i') }} + +
Quote Received
+

+ Thank you,
{{ $invitation->supplier->name }}! +

+

+ Your quote for {{ $invitation->purchaseRequest->request_number }} + has been successfully received. Our team will review all submitted quotes and get back to you shortly. +

+ + {{-- Details --}} +
+
+ Reference + {{ $invitation->purchaseRequest->request_number }} +
+
+ Submitted + {{ now()->format('d M Y, H:i') }} +
+ @if($invitation->purchaseRequest->project_name) +
+ Project + {{ $invitation->purchaseRequest->project_name }} +
+ @endif
+ +

+ This link has now been closed. No further action is required.
+ We appreciate your time and look forward to working with you. +

+
+ + + +
diff --git a/resources/views/settings/projects/index.blade.php b/resources/views/settings/projects/index.blade.php index 0f28daf..1ac4d0d 100644 --- a/resources/views/settings/projects/index.blade.php +++ b/resources/views/settings/projects/index.blade.php @@ -369,9 +369,10 @@ $allDeptsJson = json_encode($allDeptsData);

Expected format (2 tabs):
- Projects tab — columns: Company Name | Project Name
- Departments tab — columns: Company Name | Department Name
- Companies are created automatically if they don't exist. Duplicates are skipped. + Projects tab — Company Name | Project Name | Location Name | Address | Latitude | Longitude
+    ↳ Leave Location Name blank for project-only rows. Add multiple rows per project for multiple locations.
+ Departments tab — Company Name | Department Name
+ Companies are created automatically. Address, Latitude, Longitude are optional. Duplicates are skipped.

diff --git a/routes/web.php b/routes/web.php index 952e331..c739d1e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,20 @@ Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.s Route::middleware(['auth', 'verified'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + Route::get('/notifications/unread', fn() => response()->json([ + 'count' => auth()->user()->unreadNotifications()->count(), + 'items' => auth()->user()->unreadNotifications()->latest()->take(10)->get()->map(fn($n) => [ + 'id' => $n->id, + 'message' => $n->data['message'] ?? '', + 'url' => $n->data['url'] ?? null, + 'ago' => $n->created_at->diffForHumans(), + ]), + ]))->name('notifications.unread'); + + Route::post('/notifications/read-all', fn() => response()->json( + tap(auth()->user()->unreadNotifications()->update(['read_at' => now()])) + ))->name('notifications.read-all'); + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');