feat: RFQ portal, notifications, and project settings updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ghassan Yusuf 2026-06-01 11:52:21 +03:00
parent 72e6c3170e
commit dca9cd5d99
15 changed files with 1095 additions and 140 deletions

View File

@ -36,6 +36,9 @@ class RfqController extends Controller
} }
if (empty($supplierItems)) { 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.'); 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'); $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) return redirect()->route('purchase.pipeline.show', $purchaseRequest)
->with('success', $added . ' supplier(s) added. Now send them the quote request links.'); ->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
} }

View File

@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Models\RfqInvitation; use App\Models\RfqInvitation;
use App\Models\SupplierQuote; use App\Models\SupplierQuote;
use App\Models\SupplierQuoteItem; use App\Models\SupplierQuoteItem;
use App\Models\User;
use App\Notifications\QuoteReceived;
use App\Services\PurchaseStageService; use App\Services\PurchaseStageService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -35,9 +37,16 @@ class RfqPortalController extends Controller
} }
$purchaseRequest = $invitation->purchaseRequest; $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) public function submit(Request $request, string $token)
@ -49,6 +58,8 @@ class RfqPortalController extends Controller
} }
$validated = $request->validate([ $validated = $request->validate([
'terms' => ['accepted'],
'confirm_code' => ['required', 'string'],
'lead_time_days' => ['nullable', 'integer', 'min:0'], 'lead_time_days' => ['nullable', 'integer', 'min:0'],
'payment_terms' => ['nullable', 'string', 'max:200'], 'payment_terms' => ['nullable', 'string', 'max:200'],
'notes' => ['nullable', 'string', 'max:1000'], 'notes' => ['nullable', 'string', 'max:1000'],
@ -56,7 +67,16 @@ class RfqPortalController extends Controller
'items.*.unit_price' => ['required', 'numeric', 'min:0'], '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([ $quote = SupplierQuote::create([
'rfq_invitation_id' => $invitation->id, 'rfq_invitation_id' => $invitation->id,
@ -72,7 +92,7 @@ class RfqPortalController extends Controller
$total = 0; $total = 0;
foreach ($purchaseItems as $i => $item) { foreach ($purchaseItems as $i => $item) {
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0); $unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
$qty = (float)$item->quantity; $qty = (float)$item->quantity_required;
$totalPrice = round($unitPrice * $qty, 3); $totalPrice = round($unitPrice * $qty, 3);
$total += $totalPrice; $total += $totalPrice;
@ -95,6 +115,10 @@ class RfqPortalController extends Controller
app(PurchaseStageService::class)->setStage($pr, 'comparison'); 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')); return view('rfq.submitted', compact('invitation'));
} }
} }

View File

@ -187,6 +187,7 @@ class ProjectSettingController extends Controller
$parts = []; $parts = [];
if ($stats['projects_created']) $parts[] = "{$stats['projects_created']} project(s)"; 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['departments_created']) $parts[] = "{$stats['departments_created']} department(s)";
if ($stats['companies_created']) $parts[] = "{$stats['companies_created']} new company(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], 'font' => ['italic' => true, 'color' => ['rgb' => '64748b'], 'size' => 10],
]; ];
// ── Sheet 1: Projects ────────────────────────────────────────────── // ── Sheet 1: Projects (with optional locations) ────────────────────
$s1 = $spreadsheet->getActiveSheet()->setTitle('Projects'); $s1 = $spreadsheet->getActiveSheet()->setTitle('Projects');
$s1->setCellValue('A1', 'Company Name') $s1->setCellValue('A1', 'Company Name')
->setCellValue('B1', 'Project Name'); ->setCellValue('B1', 'Project Name')
$s1->getStyle('A1:B1')->applyFromArray($headerStyle); ->setCellValue('C1', 'Location Name')
->setCellValue('D1', 'Address')
->setCellValue('E1', 'Latitude')
->setCellValue('F1', 'Longitude');
$s1->getStyle('A1:F1')->applyFromArray($headerStyle);
// Sample rows
$samples = [ $samples = [
['Miknas Industrial', 'New Warehouse'], ['Miknas Industrial', 'New Warehouse', 'Main Gate', 'Industrial Area, Block 5', '24.7136', '46.6753'],
['Steel tech', 'Factory Extension'], ['Miknas Industrial', 'New Warehouse', 'Storage Yard', '', '', ''],
['Steel tech', 'New Office Block'], ['Steel tech', 'Factory Extension','Site Office', '2nd Ring Road, Riyadh', '24.6877', '46.7219'],
['Steel tech', 'New Office Block', '', '', '', ''],
]; ];
foreach ($samples as $i => $row) { foreach ($samples as $i => $row) {
$s1->setCellValue('A' . ($i + 2), $row[0]); $s1->setCellValue('A' . ($i + 2), $row[0]);
$s1->setCellValue('B' . ($i + 2), $row[1]); $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->setCellValue('A7', '* Leave Location Name blank for rows that are projects only. Company is created automatically. Duplicates are skipped.');
$s1->getStyle('A6')->applyFromArray($noteStyle); $s1->getStyle('A7')->applyFromArray($noteStyle);
$s1->mergeCells('A6:B6'); $s1->mergeCells('A7:F7');
$s1->getColumnDimension('A')->setWidth(32); $s1->getColumnDimension('A')->setWidth(28);
$s1->getColumnDimension('B')->setWidth(32); $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 ─────────────────────────────────────────── // ── Sheet 2: Departments ───────────────────────────────────────────
$s2 = new Worksheet($spreadsheet, 'Departments'); $s2 = new Worksheet($spreadsheet, 'Departments');

View File

@ -41,8 +41,11 @@ class User extends Authenticatable
* *
* @return array<string, string> * @return array<string, string>
*/ */
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; return $this->whatsapp_number;
} }

View File

@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use App\Models\RfqInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class QuoteReceived extends Notification
{
use Queueable;
public function __construct(public RfqInvitation $invitation) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$pr = $this->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),
];
}
}

View File

@ -12,6 +12,7 @@ class ProjectImportService
private array $stats = [ private array $stats = [
'companies_created' => 0, 'companies_created' => 0,
'projects_created' => 0, 'projects_created' => 0,
'locations_created' => 0,
'departments_created' => 0, 'departments_created' => 0,
'skipped' => 0, 'skipped' => 0,
]; ];
@ -56,8 +57,12 @@ class ProjectImportService
$rows = $sheet->toArray(null, true, true, false); $rows = $sheet->toArray(null, true, true, false);
$headers = $this->normalizeHeaders((array) array_shift($rows)); $headers = $this->normalizeHeaders((array) array_shift($rows));
$coIdx = $this->findCol($headers, ['company', 'company name', 'company name', 'companyname']); $coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']);
$projIdx = $this->findCol($headers, ['project', 'project name', 'project name', 'projectname']); $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) { if ($coIdx === null || $projIdx === null) {
return; return;
@ -73,16 +78,37 @@ class ProjectImportService
} }
$company = $this->findOrCreateCompany($coName); $company = $this->findOrCreateCompany($coName);
$existing = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)]) $project = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
->where('company_id', $company->id)->first(); ->where('company_id', $company->id)->first();
if ($existing) { if (!$project) {
$this->stats['skipped']++; $project = ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
continue; $this->stats['projects_created']++;
} }
ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]); // If a location name is present on this row, import it too
$this->stats['projects_created']++; $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']++;
}
}
} }
} }

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};

View File

@ -41,6 +41,10 @@ $editProjectsJson = json_encode($editProjectsData);
$curProjectInList = $editProjects->contains('name', $curProject); $curProjectInList = $editProjects->contains('name', $curProject);
@endphp @endphp
<style>
.{{ $prefix }}-urgency-opt:hover { background:#f8fafc; }
</style>
{{-- ── Trigger button ── --}} {{-- ── Trigger button ── --}}
<button type="button" onclick="{{ $prefix }}Open()" class="btn-secondary btn-sm"> <button type="button" onclick="{{ $prefix }}Open()" class="btn-secondary btn-sm">
Edit Edit
@ -119,9 +123,67 @@ $curProjectInList = $editProjects->contains('name', $curProject);
<label class="form-label">Requested By <span class="text-red-500">*</span></label> <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"> <input type="text" name="requested_by_name" value="{{ $curReqBy }}" required class="form-input" placeholder="Person's name">
</div> </div>
<div> <div style="position:relative;" id="{{ $prefix }}UrgencyWrapper">
<label class="form-label">Required Date / Urgency</label> <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"> <input type="hidden" name="required_date_text" id="{{ $prefix }}UrgencyValue" value="{{ $curReqDate }}">
{{-- Trigger --}}
<button type="button" id="{{ $prefix }}UrgencyTrigger" onclick="{{ $prefix }}UrgencyToggle()"
style="width:100%;text-align:left;background:white;border:1.5px dashed #d1d5db;border-radius:0.5rem;padding:0.45rem 2rem 0.45rem 0.75rem;font-size:0.8rem;cursor:pointer;position:relative;min-height:2.35rem;display:flex;align-items:center;transition:border-color 0.15s;">
<span id="{{ $prefix }}UrgencyDisplay" style="display:flex;align-items:center;gap:0.5rem;flex:1;">
<span style="color:#9ca3af;font-size:0.8rem;"> Select urgency </span>
</span>
<span style="position:absolute;right:0.6rem;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:0.7rem;">&#9660;</span>
</button>
{{-- Panel --}}
<div id="{{ $prefix }}UrgencyPanel"
style="display:none;position:absolute;z-index:10000;top:calc(100% + 4px);left:0;right:0;background:white;border:1px solid #e2e8f0;border-radius:0.75rem;box-shadow:0 12px 32px -4px rgba(0,0,0,0.18);overflow:hidden;">
<ul style="list-style:none;margin:0;padding:0.375rem;">
<li onclick="{{ $prefix }}UrgencySelect('Urgent','Urgent','#dc2626','#fef2f2')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fef2f2;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#dc2626;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#dc2626;line-height:1.2;">Urgent</div><div style="font-size:0.68rem;color:#9ca3af;">Needed immediately</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('3 Days','3 Days','#ea580c','#fff7ed')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fff7ed;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#ea580c;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#ea580c;line-height:1.2;">3 Days</div><div style="font-size:0.68rem;color:#9ca3af;">Within 3 days</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('1 Week','1 Week','#d97706','#fffbeb')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fffbeb;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#d97706;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#d97706;line-height:1.2;">1 Week</div><div style="font-size:0.68rem;color:#9ca3af;">Within this week</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('2 Weeks','2 Weeks','#2563eb','#eff6ff')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#eff6ff;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#2563eb;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#2563eb;line-height:1.2;">2 Weeks</div><div style="font-size:0.68rem;color:#9ca3af;">Within 2 weeks</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('1 Month','1 Month','#16a34a','#f0fdf4')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f0fdf4;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#16a34a;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#16a34a;line-height:1.2;">1 Month</div><div style="font-size:0.68rem;color:#9ca3af;">Within a month</div></div>
</li>
<li style="height:1px;background:#f1f5f9;margin:0.25rem 0;"></li>
<li id="{{ $prefix }}UrgencyDateOpt" onclick="{{ $prefix }}UrgencyDateOptClick()"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<svg style="width:0.875rem;height:0.875rem;stroke:#7c3aed;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</span>
<div style="flex:1;"><div style="font-size:0.8rem;font-weight:700;color:#7c3aed;line-height:1.2;">Specific Date</div><div style="font-size:0.68rem;color:#9ca3af;">Pick an exact date</div></div>
</li>
<li id="{{ $prefix }}UrgencyDatePickerRow" style="display:none;padding:0 0.75rem 0.5rem;">
<input type="date" id="{{ $prefix }}UrgencyDate"
style="width:100%;font-size:0.8rem;border:1.5px solid #7c3aed;border-radius:0.4rem;padding:0.35rem 0.5rem;outline:none;color:#7c3aed;box-sizing:border-box;"
onchange="{{ $prefix }}UrgencyDateChange(this.value)">
</li>
</ul>
</div>
</div> </div>
<div> <div>
<label class="form-label">Location / Site</label> <label class="form-label">Location / Site</label>
@ -315,6 +377,87 @@ $curProjectInList = $editProjects->contains('name', $curProject);
} }
window.{{ $prefix }}FilterLoc = {{ $prefix }}FilterLoc; 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 '<span style="display:inline-flex;align-items:center;gap:0.4rem;background:' + bgColor + ';border:1px solid ' + color + '20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<span style="width:0.45rem;height:0.45rem;border-radius:50%;background:' + color + ';flex-shrink:0;"></span>' +
'<span style="font-size:0.78rem;font-weight:600;color:' + color + ';">' + label + '</span></span>';
}
function _urgencyDateBadge{{ $prId }}(label) {
return '<span style="display:inline-flex;align-items:center;gap:0.4rem;background:#f5f3ff;border:1px solid #7c3aed20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<svg style="width:0.7rem;height:0.7rem;stroke:#7c3aed;flex-shrink:0;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'<span style="font-size:0.78rem;font-weight:600;color:#7c3aed;">' + label + '</span></span>';
}
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 // On load: populate locations and pre-select the current ones
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var curProj = document.getElementById('{{ $prefix }}Project').value; var curProj = document.getElementById('{{ $prefix }}Project').value;

View File

@ -1,20 +1,38 @@
@php @php
$hasErrors = $errors->any(); $hasErrors = $errors->any();
$mprProjects = \App\Models\Settings\ProjectSetting::active() $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') ->orderBy('name')
->get(); ->get();
$mprProjectsJson = $mprProjects->map(function ($p) { $mprProjectsJson = $mprProjects->map(function ($p) {
return [ return [
'id' => $p->id, 'id' => $p->id,
'name' => $p->name, '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) { 'locations' => $p->locations->map(function ($l) {
return ['name' => $l->name]; return ['name' => $l->name];
})->values(), })->values(),
]; ];
})->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 @endphp
<style>
.mpr-proj-opt:hover { background:#eff6ff; border-left-color:#2563eb !important; }
.mpr-urgency-opt:hover { background:#f8fafc; }
</style>
{{-- Trigger button --}} {{-- Trigger button --}}
<button type="button" onclick="mprModalOpen()" class="btn-primary"> <button type="button" onclick="mprModalOpen()" class="btn-primary">
+ New Request + New Request
@ -72,24 +90,139 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;"> <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;">
<div> <div>
<label class="form-label">Date <span class="text-red-500">*</span></label> <label class="form-label">Date <span class="text-red-500">*</span></label>
<input type="date" name="date" value="{{ old('date', date('Y-m-d')) }}" required class="form-input"> <input type="date" name="date" value="{{ old('date', $today) }}" required class="form-input">
</div> </div>
<div> <div style="position:relative;" id="mpr-project-wrapper">
<label class="form-label">Project / Site Name <span class="text-red-500">*</span></label> <label class="form-label">Project / Site Name <span class="text-red-500">*</span></label>
<select name="project_name" id="mpr-project-select" required class="form-input" onchange="mprFilterLocations(this.value)"> {{-- Hidden input carries the actual value for form submission --}}
<option value=""> Select Project </option> <input type="hidden" name="project_name" id="mpr-project-value" value="{{ old('project_name') }}">
{{-- Trigger --}}
<button type="button" id="mpr-project-trigger"
onclick="mprProjectToggle()"
style="width:100%;text-align:left;background:white;border:1px solid #d1d5db;border-radius:0.375rem;padding:0.4rem 2rem 0.4rem 0.625rem;font-size:0.875rem;color:#111827;cursor:pointer;position:relative;line-height:1.5;min-height:2.25rem;">
<span id="mpr-project-label" style="color:#9ca3af;"> Select Project </span>
<span style="position:absolute;right:0.5rem;top:50%;transform:translateY(-50%);pointer-events:none;color:#6b7280;">&#9660;</span>
</button>
{{-- Dropdown panel --}}
<div id="mpr-project-panel"
style="display:none;position:absolute;z-index:9999;top:calc(100% + 2px);left:0;right:0;background:white;border:1px solid #d1d5db;border-radius:0.5rem;box-shadow:0 10px 25px -5px rgba(0,0,0,0.15);overflow:hidden;">
<div style="padding:0.5rem;">
<input type="text" id="mpr-project-search" placeholder="Search project or company…"
oninput="mprProjectSearch(this.value)"
style="width:100%;border:1px solid #e2e8f0;border-radius:0.375rem;padding:0.375rem 0.625rem;font-size:0.8rem;outline:none;box-sizing:border-box;">
</div>
<ul id="mpr-project-list"
style="max-height:13rem;overflow-y:auto;margin:0;padding:0 0 0.25rem 0;list-style:none;">
@foreach($mprProjects as $proj) @foreach($mprProjects as $proj)
<option value="{{ $proj->name }}" {{ old('project_name') == $proj->name ? 'selected' : '' }} data-id="{{ $proj->id }}">{{ $proj->name }}</option> <li class="mpr-proj-opt"
data-value="{{ $proj->name }}"
data-label="{{ ($proj->company ? $proj->company->name . ' — ' : '') . $proj->name }}"
data-search="{{ strtolower(($proj->company ? $proj->company->name . ' ' : '') . $proj->name) }}"
onclick="mprProjectSelect('{{ $proj->name }}', '{{ addslashes(($proj->company ? $proj->company->name . ' — ' : '') . $proj->name) }}')"
style="padding:0.45rem 0.875rem;cursor:pointer;font-size:0.8rem;color:#111827;line-height:1.4;border-left:3px solid transparent;">
<span style="font-size:0.68rem;color:#6b7280;display:block;line-height:1.2;">{{ $proj->company ? $proj->company->name : '' }}</span>
{{ $proj->name }}
</li>
@endforeach @endforeach
</select> <li id="mpr-proj-empty" style="display:none;padding:0.625rem 0.875rem;font-size:0.8rem;color:#9ca3af;">No projects found.</li>
</ul>
</div>
</div> </div>
<div> <div>
<label class="form-label">Requested By <span class="text-red-500">*</span></label> <label class="form-label">Requested By <span class="text-red-500">*</span></label>
<input type="text" name="requested_by_name" value="{{ old('requested_by_name') }}" required class="form-input" placeholder="Person's name"> <input type="text" name="requested_by_name" value="{{ old('requested_by_name') }}" required class="form-input" placeholder="Person's name">
</div> </div>
<div> <div style="position:relative;" id="mpr-urgency-wrapper">
<label class="form-label">Required Date / Urgency</label> <label class="form-label">Required Date / Urgency</label>
<input type="text" name="required_date_text" value="{{ old('required_date_text') }}" class="form-input" placeholder="e.g. Urgent, or 2026-06-01"> <input type="hidden" name="required_date_text" id="mpr-urgency-value" value="{{ old('required_date_text') }}">
{{-- Trigger --}}
<button type="button" id="mpr-urgency-trigger" onclick="mprUrgencyToggle()"
style="width:100%;text-align:left;background:white;border:1.5px dashed #d1d5db;border-radius:0.5rem;padding:0.45rem 2rem 0.45rem 0.75rem;font-size:0.8rem;cursor:pointer;position:relative;min-height:2.35rem;display:flex;align-items:center;transition:border-color 0.15s;">
<span id="mpr-urgency-display" style="display:flex;align-items:center;gap:0.5rem;flex:1;">
<span style="color:#9ca3af;font-size:0.8rem;"> Select urgency </span>
</span>
<span style="position:absolute;right:0.6rem;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:0.7rem;">&#9660;</span>
</button>
{{-- Panel --}}
<div id="mpr-urgency-panel"
style="display:none;position:absolute;z-index:10000;top:calc(100% + 4px);left:0;right:0;background:white;border:1px solid #e2e8f0;border-radius:0.75rem;box-shadow:0 12px 32px -4px rgba(0,0,0,0.18);overflow:hidden;">
<ul style="list-style:none;margin:0;padding:0.375rem;">
<li onclick="mprUrgencySelect('Urgent','Urgent','#dc2626','#fef2f2')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fef2f2;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#dc2626;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#dc2626;line-height:1.2;">Urgent</div>
<div style="font-size:0.68rem;color:#9ca3af;">Needed immediately</div>
</div>
</li>
<li onclick="mprUrgencySelect('3 Days','3 Days','#ea580c','#fff7ed')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fff7ed;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#ea580c;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#ea580c;line-height:1.2;">3 Days</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within 3 days</div>
</div>
</li>
<li onclick="mprUrgencySelect('1 Week','1 Week','#d97706','#fffbeb')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fffbeb;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#d97706;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#d97706;line-height:1.2;">1 Week</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within this week</div>
</div>
</li>
<li onclick="mprUrgencySelect('2 Weeks','2 Weeks','#2563eb','#eff6ff')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#eff6ff;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#2563eb;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#2563eb;line-height:1.2;">2 Weeks</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within 2 weeks</div>
</div>
</li>
<li onclick="mprUrgencySelect('1 Month','1 Month','#16a34a','#f0fdf4')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f0fdf4;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#16a34a;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#16a34a;line-height:1.2;">1 Month</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within a month</div>
</div>
</li>
<li style="height:1px;background:#f1f5f9;margin:0.25rem 0;"></li>
<li id="mpr-urgency-date-opt" onclick="mprUrgencyDateOptClick()"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<svg style="width:0.875rem;height:0.875rem;stroke:#7c3aed;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</span>
<div style="flex:1;">
<div style="font-size:0.8rem;font-weight:700;color:#7c3aed;line-height:1.2;">Specific Date</div>
<div style="font-size:0.68rem;color:#9ca3af;">Pick an exact date</div>
</div>
</li>
<li id="mpr-urgency-datepicker-row" style="display:none;padding:0 0.75rem 0.5rem;">
<input type="date" id="mpr-urgency-date"
style="width:100%;font-size:0.8rem;border:1.5px solid #7c3aed;border-radius:0.4rem;padding:0.35rem 0.5rem;outline:none;color:#7c3aed;box-sizing:border-box;"
onchange="mprUrgencyDateChange(this.value)">
</li>
</ul>
</div>
</div> </div>
<div> <div>
<label class="form-label">Location / Site</label> <label class="form-label">Location / Site</label>
@ -99,7 +232,12 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
</div> </div>
<div> <div>
<label class="form-label">Department</label> <label class="form-label">Department</label>
<input type="text" name="department" value="{{ old('department') }}" class="form-input" placeholder="e.g. Operations"> <select name="department" id="mpr-department-select" class="form-input">
<option value=""> Select Department </option>
@foreach($mprDepartments as $dept)
<option value="{{ $dept->name }}" data-company="{{ $dept->company_id }}" {{ old('department') == $dept->name ? 'selected' : '' }}>{{ $dept->name }}</option>
@endforeach
</select>
</div> </div>
</div> </div>
</div> </div>
@ -119,7 +257,7 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
<tr style="background:white;"> <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;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;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;">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: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: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.625rem;text-align:left;width:8rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Req. Date</th>
@ -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> style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required>
</td> </td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"> <td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="text" name="items[{{ $idx }}][unit]" value="{{ $oldItem['unit'] ?? '' }}" <select name="items[{{ $idx }}][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;cursor:pointer;">
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="PCS…"> <option value=""></option>
@foreach($mprUnits as $u)
<option value="{{ $u }}" {{ ($oldItem['unit'] ?? '') == $u ? 'selected' : '' }}>{{ $u }}</option>
@endforeach
</select>
</td> </td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"> <td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="number" name="items[{{ $idx }}][quantity_required]" value="{{ $oldItem['quantity_required'] ?? '' }}" <input type="number" name="items[{{ $idx }}][quantity_required]" value="{{ $oldItem['quantity_required'] ?? '' }}"
@ -148,7 +290,7 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…"> style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…">
</td> </td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"> <td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="date" name="items[{{ $idx }}][required_date]" value="{{ $oldItem['required_date'] ?? '' }}" <input type="date" name="items[{{ $idx }}][required_date]" value="{{ $oldItem['required_date'] ?? $today }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;"> style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;">
</td> </td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;"> <td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;">
@ -204,6 +346,13 @@ document.addEventListener('keydown', function (e) {
document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); }); document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
@endif @endif
// Unit options HTML (shared between template rows and JS-added rows)
var mprUnitOptions = '<option value="">—</option>' +
@foreach($mprUnits as $u)
'<option value="{{ $u }}">{{ $u }}</option>' +
@endforeach
'';
// Dynamic rows // Dynamic rows
(function () { (function () {
var mprRowIndex = {{ count($oldItems ?? [[]]) }}; 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() { function mprNewRow() {
var idx = mprRowIndex++; var idx = mprRowIndex++;
var date = mprLastRowDate();
var tr = document.createElement('tr'); var tr = document.createElement('tr');
tr.className = 'mpr-item-row'; tr.className = 'mpr-item-row';
tr.style.background = 'white'; tr.style.background = 'white';
tr.innerHTML = tr.innerHTML =
'<td style="border:1px solid #e2e8f0;padding:0.375rem 0.625rem;text-align:center;color:#cbd5e1;font-size:0.75rem;" class="mpr-row-num"></td>' + '<td style="border:1px solid #e2e8f0;padding:0.375rem 0.625rem;text-align:center;color:#cbd5e1;font-size:0.75rem;" class="mpr-row-num"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][description]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required></td>' + '<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][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;"><select name="items[' + idx + '][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;cursor:pointer;">' + mprUnitOptions + '</select></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="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="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.5rem;"><input type="date" name="items[' + idx + '][required_date]" value="' + date + '" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;"><button type="button" class="mpr-remove-row" style="color:#f87171;background:none;border:none;cursor:pointer;font-size:1.1rem;font-weight:700;line-height:1;" onmouseover="this.style.color=\'#dc2626\'" onmouseout="this.style.color=\'#f87171\'">&times;</button></td>'; '<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;"><button type="button" class="mpr-remove-row" style="color:#f87171;background:none;border:none;cursor:pointer;font-size:1.1rem;font-weight:700;line-height:1;" onmouseover="this.style.color=\'#dc2626\'" onmouseout="this.style.color=\'#f87171\'">&times;</button></td>';
return tr; return tr;
} }
@ -253,11 +410,61 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
}); });
})(); })();
// Cascading project → location dropdown // ── Cascading data ────────────────────────────────────────────────────────────
var mprProjectsData = @json($mprProjectsJson); var mprProjectsData = @json($mprProjectsJson);
var mprDepartmentsData = @json($mprDepartmentsJson);
var mprOldLocation = "{{ old('location') }}"; var mprOldLocation = "{{ old('location') }}";
var mprOldProjectName = "{{ old('project_name') }}"; 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) { function mprFilterLocations(projectName) {
var sel = document.getElementById('mpr-location-select'); var sel = document.getElementById('mpr-location-select');
sel.innerHTML = '<option value="">— Select Location —</option>'; sel.innerHTML = '<option value="">— Select Location —</option>';
@ -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 = '<option value="">— Select Department —</option>';
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(){ document.addEventListener('DOMContentLoaded', function(){
if (mprOldProjectName) { if (mprOldProjectName) {
mprFilterLocations(mprOldProjectName); var proj = mprProjectsData.find(function(p){ return p.name === mprOldProjectName; });
if (proj) mprProjectSelect(proj.name, proj.label);
} else { } else {
document.getElementById('mpr-location-select').disabled = true; 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 =
'<span style="display:inline-flex;align-items:center;gap:0.4rem;background:' + bgColor + ';border:1px solid ' + color + '20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<span style="width:0.45rem;height:0.45rem;border-radius:50%;background:' + color + ';flex-shrink:0;"></span>' +
'<span style="font-size:0.78rem;font-weight:600;color:' + color + ';">' + label + '</span>' +
'</span>';
// 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 =
'<span style="display:inline-flex;align-items:center;gap:0.4rem;background:#f5f3ff;border:1px solid #7c3aed20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<svg style="width:0.7rem;height:0.7rem;stroke:#7c3aed;flex-shrink:0;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'<span style="font-size:0.78rem;font-weight:600;color:#7c3aed;">' + label + '</span>' +
'</span>';
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 =
'<span style="display:inline-flex;align-items:center;gap:0.4rem;background:#f5f3ff;border:1px solid #7c3aed20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<svg style="width:0.7rem;height:0.7rem;stroke:#7c3aed;flex-shrink:0;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'<span style="font-size:0.78rem;font-weight:600;color:#7c3aed;">' + label + '</span>' +
'</span>';
document.getElementById('mpr-urgency-trigger').style.borderColor = '#7c3aed';
document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid';
}
}
</script> </script>

View File

@ -256,18 +256,15 @@
</div>{{-- /sup-step2 --}} </div>{{-- /sup-step2 --}}
{{-- Step 3: Summary / confirmation (By Item only) --}} {{-- Step 3: Links --}}
<div id="sup-step3" style="flex:1;display:none;flex-direction:column;min-height:0;"> <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 id="sup-summary-body" style="flex:1;overflow-y:auto;overscroll-behavior:contain;padding:20px 24px;">
</div> </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;"> <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()" <div style="font-size:12px;color:#64748b;" id="sup-link-count"></div>
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;"> <button type="button" onclick="doneWithLinks()"
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;"> style="padding:8px 22px;background:#16a34a;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">
Confirm &amp; Send Done
</button> </button>
</div> </div>
</div> </div>
@ -520,69 +517,117 @@ function updateFooter() {
} }
} }
var _rfqRedirect = null;
function submitSuppliers() { function submitSuppliers() {
if (_supTab === 'global') { if (_supTab === 'global') {
var checked = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])'); 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; } if (checked.length === 0) { showToast('Please select at least one supplier.', 'warn'); return; }
document.getElementById('sup-form').submit();
} else { } else {
var checked = document.querySelectorAll('input[name^="item_suppliers["]:checked:not([disabled])'); 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; } 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 =
'<div style="text-align:center;padding:40px;color:#64748b;font-size:13px;">Generating links…</div>';
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) { function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function showSummary() { var _chanBadge = {
var chanLabel = { email: '<span style="background:#eff6ff;color:#2563eb;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;flex-shrink:0;">Email</span>',
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:11px;font-weight:700;flex-shrink:0;">WhatsApp</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:11px;font-weight:700;flex-shrink:0;">Email + WA</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-"]'); function showLinks(invitations) {
btns.forEach(function(btn) { var html = '';
var itemId = btn.id.replace('idd-btn-', ''); if (!invitations || invitations.length === 0) {
var checked = document.querySelectorAll('input[name="item_suppliers[' + itemId + '][]"]:checked:not([disabled])'); html = '<div style="padding:30px;text-align:center;color:#94a3b8;font-size:13px;">No new invitations were created.</div>';
if (checked.length === 0) return; } else {
html += '<div style="font-size:12px;color:#64748b;margin-bottom:16px;">Each link is private to the supplier and valid until submitted or expired.</div>';
var name = btn.dataset.itemname || ('Item ' + itemId); invitations.forEach(function(inv, i) {
var qty = btn.dataset.itemqty || ''; var badge = _chanBadge[inv.channel] || '';
html += '<div style="margin-bottom:10px;border:1.5px solid #e2e8f0;border-radius:10px;overflow:hidden;">';
html += '<div style="margin-bottom:12px;border:1.5px solid #e2e8f0;border-radius:10px;overflow:hidden;">'; html += '<div style="padding:9px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between;gap:8px;">';
html += '<div style="padding:10px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;">'; html += '<span style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(inv.supplier_name) + '</span>';
html += '<div style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(name) + '</div>'; html += badge;
if (qty) html += '<div style="font-size:11px;color:#94a3b8;margin-top:1px;">Qty: ' + escHtml(qty) + '</div>'; html += '</div>';
html += '<div style="padding:10px 14px;display:flex;align-items:center;gap:8px;">';
html += '<a href="' + escHtml(inv.url) + '" target="_blank"'
+ ' style="flex:1;display:flex;align-items:center;gap:8px;padding:9px 14px;'
+ 'background:#eff6ff;border:1.5px solid #bfdbfe;border-radius:8px;text-decoration:none;'
+ 'color:#1d4ed8;font-size:12px;font-weight:600;transition:background .15s;overflow:hidden;"'
+ ' onmouseover="this.style.background=\'#dbeafe\'" onmouseout="this.style.background=\'#eff6ff\'">'
+ '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;">'
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>'
+ '</svg>'
+ '<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Open Quote Link</span>'
+ '</a>';
html += '<button type="button" data-idx="' + i + '" data-url="' + escHtml(inv.url) + '" onclick="copyLink(this)"'
+ ' style="flex-shrink:0;padding:9px 14px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;">Copy Link</button>';
html += '</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>'; html += '</div>';
}); });
}
document.getElementById('sup-summary-body').innerHTML = html; document.getElementById('sup-summary-body').innerHTML = html;
document.getElementById('sup-modal-title').textContent = 'Confirm Assignments'; document.getElementById('sup-modal-title').textContent = 'Quote Links Ready';
document.getElementById('sup-modal-subtitle').textContent = 'Review before sending to suppliers'; document.getElementById('sup-modal-subtitle').textContent = 'Share with suppliers via their preferred channel';
document.getElementById('sup-step2').style.display = 'none'; var countEl = document.getElementById('sup-link-count');
document.getElementById('sup-step3').style.display = 'flex'; if (countEl) countEl.textContent = invitations.length + ' link' + (invitations.length === 1 ? '' : 's') + ' generated';
} }
function backToEdit() { function copyLink(btn) {
document.getElementById('sup-modal-title').textContent = 'Select Suppliers'; var url = btn.dataset.url;
document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request'; if (!url) return;
document.getElementById('sup-step3').style.display = 'none'; navigator.clipboard.writeText(url).then(function() {
document.getElementById('sup-step2').style.display = 'flex'; 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(); }
} }
</script> </script>

View File

@ -249,6 +249,38 @@
<div style="display:flex;align-items:center;gap:16px;"> <div style="display:flex;align-items:center;gap:16px;">
<span style="font-size:12px;color:#94a3b8;">{{ now()->format('l, d M Y') }}</span> <span style="font-size:12px;color:#94a3b8;">{{ now()->format('l, d M Y') }}</span>
<div style="width:1px;height:20px;background:#e2e8f0;"></div>
{{-- Bell notification --}}
<div style="position:relative;" id="bell-wrap">
<button id="bell-btn" onclick="toggleBellDropdown()" title="Notifications"
style="position:relative;width:34px;height:34px;border-radius:9px;border:1px solid #e2e8f0;
background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#64748b;
transition:background .15s,color .15s;"
onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='#fff'">
<svg width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
<span id="bell-badge" style="display:none;position:absolute;top:-4px;right:-4px;
background:#ef4444;color:#fff;font-size:10px;font-weight:700;
min-width:17px;height:17px;border-radius:9px;padding:0 4px;
display:none;align-items:center;justify-content:center;border:2px solid #fff;"></span>
</button>
{{-- Dropdown --}}
<div id="bell-dropdown" style="display:none;position:absolute;top:42px;right:0;
background:#fff;border:1.5px solid #e2e8f0;border-radius:14px;
box-shadow:0 12px 32px rgba(0,0,0,.12);width:320px;z-index:9000;overflow:hidden;">
<div style="padding:12px 16px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;font-weight:700;color:#0f172a;">Notifications</span>
<button onclick="markAllRead()" style="font-size:11px;color:#2563eb;background:none;border:none;cursor:pointer;font-weight:600;">Mark all read</button>
</div>
<div id="bell-list" style="max-height:320px;overflow-y:auto;">
<div style="padding:24px;text-align:center;color:#94a3b8;font-size:13px;" id="bell-empty">No new notifications</div>
</div>
</div>
</div>
<div style="width:1px;height:20px;background:#e2e8f0;"></div> <div style="width:1px;height:20px;background:#e2e8f0;"></div>
<div style="display:flex;align-items:center;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;">
<div style="width:32px;height:32px;border-radius:50%;background:#2563eb;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;"> <div style="width:32px;height:32px;border-radius:50%;background:#2563eb;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;">
@ -493,6 +525,77 @@ document.addEventListener('keydown', function(e) {
}); });
</script> </script>
<script>
// ── Bell notifications ────────────────────────────────────
(function() {
var bellOpen = false;
function fetchNotifications() {
fetch('{{ route('notifications.unread') }}', { headers: { 'Accept': 'application/json' } })
.then(function(r){ return r.json(); })
.then(function(data) {
var badge = document.getElementById('bell-badge');
var list = document.getElementById('bell-list');
var empty = document.getElementById('bell-empty');
if (!badge) return;
if (data.count > 0) {
badge.textContent = data.count > 9 ? '9+' : data.count;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
if (data.items && data.items.length > 0) {
empty.style.display = 'none';
var html = '';
data.items.forEach(function(n) {
html += '<a href="' + (n.url || '#') + '" onclick="markAllRead()" style="display:block;padding:12px 16px;border-bottom:1px solid #f8fafc;text-decoration:none;transition:background .1s;" onmouseover="this.style.background=\'#f8fafc\'" onmouseout="this.style.background=\'transparent\'">';
html += '<div style="font-size:12px;font-weight:600;color:#0f172a;line-height:1.4;">' + n.message + '</div>';
html += '<div style="font-size:11px;color:#94a3b8;margin-top:2px;">' + n.ago + '</div>';
html += '</a>';
});
list.innerHTML = html + '<div id="bell-empty" style="display:none;"></div>';
} else {
list.innerHTML = '<div style="padding:24px;text-align:center;color:#94a3b8;font-size:13px;" id="bell-empty">No new notifications</div>';
}
}).catch(function(){});
}
window.toggleBellDropdown = function() {
var dd = document.getElementById('bell-dropdown');
bellOpen = !bellOpen;
dd.style.display = bellOpen ? 'block' : 'none';
if (bellOpen) fetchNotifications();
};
window.markAllRead = function() {
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
fetch('{{ route('notifications.read-all') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' }
}).then(function() {
document.getElementById('bell-badge').style.display = 'none';
document.getElementById('bell-dropdown').style.display = 'none';
bellOpen = false;
});
};
// Close dropdown on outside click
document.addEventListener('click', function(e) {
var wrap = document.getElementById('bell-wrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('bell-dropdown').style.display = 'none';
bellOpen = false;
}
});
// Poll every 30 seconds
fetchNotifications();
setInterval(fetchNotifications, 30000);
}());
</script>
<script> <script>
function toggleSidebar() { function toggleSidebar() {
var s = document.getElementById('sidebar'); var s = document.getElementById('sidebar');

View File

@ -6,31 +6,50 @@
<title>Quote Request {{ $purchaseRequest->request_number }}</title> <title>Quote Request {{ $purchaseRequest->request_number }}</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0;} *{box-sizing:border-box;margin:0;padding:0;}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;padding:20px 16px;} body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:740px;margin:0 auto;overflow:hidden;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 32px;}
.body{padding:32px;}
label.fl{display:block;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;} label.fl{display:block;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;}
input[type=text],input[type=number],textarea{width:100%;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;outline:none;} input[type=text],input[type=number],textarea{width:100%;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;outline:none;background:#fff;}
input:focus,textarea:focus{border-color:#2563eb;} input:focus,textarea:focus{border-color:#2563eb;}
.btn{width:100%;padding:14px;background:linear-gradient(135deg,#2563eb,#1d4ed8);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;margin-top:24px;} .btn{width:100%;padding:15px;background:linear-gradient(135deg,#2563eb,#1d4ed8);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;}
table{width:100%;border-collapse:collapse;font-size:13px;} table{width:100%;border-collapse:collapse;font-size:13px;}
thead th{background:#f8fafc;padding:10px 12px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;} thead th{background:#f8fafc;padding:10px 12px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:middle;} tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
tfoot td{padding:12px;font-weight:700;} tfoot td{padding:12px;font-weight:700;}
input[type=number].price{width:130px;text-align:right;} input[type=number].price{width:130px;text-align:right;}
@media(max-width:600px){ .two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;}
.body{padding:20px;}
/* ── Desktop: centered card ── */
@media(min-width:641px){
body{padding:28px 16px;}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:740px;margin:0 auto;overflow:hidden;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 32px;}
.body{padding:32px;}
}
/* ── Mobile: full page ── */
@media(max-width:640px){
body{background:#fff;padding:0;}
.card{background:#fff;border-radius:0;box-shadow:none;max-width:100%;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:22px 18px;}
.body{padding:18px 16px 32px;}
.two-col{grid-template-columns:1fr;}
/* Items table → stacked cards */
table,thead,tbody,tfoot,tr,th,td{display:block;} table,thead,tbody,tfoot,tr,th,td{display:block;}
thead{display:none;} thead{display:none;}
tbody td{border:none;padding:4px 0;} tbody td{border:none;padding:3px 0;}
tbody tr{border:1px solid #e2e8f0;border-radius:10px;padding:12px;margin-bottom:10px;} tbody tr{border:1px solid #e2e8f0;border-radius:10px;padding:12px 14px;margin-bottom:10px;background:#fff;}
tfoot tr{border-top:2px solid #e2e8f0;padding:12px 0;}
input[type=number].price{width:100%;} input[type=number].price{width:100%;}
/* Confirm code stacks */
.code-row{flex-direction:column !important;align-items:stretch !important;}
.code-display{display:block;text-align:center;}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
{{-- Header --}}
<div class="hd"> <div class="hd">
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Request for Quotation</div> <div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Request for Quotation</div>
<div style="font-size:22px;font-weight:700;color:#fff;margin-top:4px;">{{ $purchaseRequest->request_number }}</div> <div style="font-size:22px;font-weight:700;color:#fff;margin-top:4px;">{{ $purchaseRequest->request_number }}</div>
@ -68,12 +87,12 @@
<tr> <tr>
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td> <td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td>
<td style="font-weight:500;">{{ $item->description }}</td> <td style="font-weight:500;">{{ $item->description }}</td>
<td>{{ rtrim(rtrim(number_format((float)$item->quantity, 3), '0'), '.') }}</td> <td>{{ rtrim(rtrim(number_format((float)$item->quantity_required, 3), '0'), '.') }}</td>
<td style="color:#64748b;">{{ $item->unit ?: '—' }}</td> <td style="color:#64748b;">{{ $item->unit ?: '—' }}</td>
<td style="text-align:right;"> <td style="text-align:right;">
<input type="number" class="price" name="items[{{ $i }}][unit_price]" <input type="number" class="price" name="items[{{ $i }}][unit_price]"
min="0" step="0.001" required placeholder="0.000" min="0" step="0.001" required placeholder="0.000"
oninput="calcRow({{ $i }}, {{ (float)$item->quantity }})"> oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
</td> </td>
<td style="text-align:right;font-weight:600;" id="tot-{{ $i }}"></td> <td style="text-align:right;font-weight:600;" id="tot-{{ $i }}"></td>
</tr> </tr>
@ -88,10 +107,10 @@
</table> </table>
</div> </div>
{{-- Terms --}} {{-- Delivery / Payment --}}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;"> <div class="two-col">
<div> <div>
<label class="fl">Lead Time (days)</label> <label class="fl">Delivery Time (days)</label>
<input type="number" name="lead_time_days" min="0" placeholder="e.g. 14"> <input type="number" name="lead_time_days" min="0" placeholder="e.g. 14">
</div> </div>
<div> <div>
@ -99,15 +118,86 @@
<input type="text" name="payment_terms" placeholder="e.g. 30 days net"> <input type="text" name="payment_terms" placeholder="e.g. 30 days net">
</div> </div>
</div> </div>
<div> <div style="margin-bottom:20px;">
<label class="fl">Notes / Remarks</label> <label class="fl">Notes / Remarks</label>
<textarea name="notes" rows="3" placeholder="Any additional notes, conditions, or remarks…"></textarea> <textarea name="notes" rows="3" placeholder="Any additional notes, conditions, or remarks…"></textarea>
</div> </div>
<button class="btn" type="submit">Submit My Quote </button> {{-- T&C --}}
<div style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:10px;padding:16px 18px;margin-bottom:16px;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Terms &amp; Conditions</div>
<ul style="list-style:none;padding:0;margin:0 0 14px;display:flex;flex-direction:column;gap:7px;">
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
Prices stated in this quote are valid for <strong>30 days</strong> from the submission date.
</li>
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
Delivery will be made within the specified delivery time stated above.
</li>
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
All items supplied will meet the required specifications and quality standards.
</li>
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
This quote is <strong>binding upon acceptance</strong> and constitutes a formal offer.
</li>
</ul>
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:10px 12px;background:#fff;border:1.5px solid #e2e8f0;border-radius:8px;">
<input type="checkbox" name="terms" id="terms-cb" value="1" required
onchange="checkReady()"
style="width:16px;height:16px;margin-top:1px;accent-color:#2563eb;flex-shrink:0;cursor:pointer;">
<span style="font-size:13px;font-weight:600;color:#0f172a;">I have read and agree to the terms and conditions above</span>
</label>
@error('terms')
<div style="font-size:12px;color:#dc2626;margin-top:6px;">{{ $message }}</div>
@enderror
</div>
{{-- Confirmation code --}}
<div style="background:#fffbeb;border:1.5px solid #fde68a;border-radius:10px;padding:16px 18px;margin-bottom:20px;">
<div style="font-size:11px;font-weight:700;color:#92400e;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Confirmation Required</div>
<div class="code-row" style="display:flex;align-items:center;gap:14px;">
<div style="flex-shrink:0;">
<div style="font-size:11px;color:#92400e;margin-bottom:6px;">Copy this code:</div>
<div class="code-display"
style="font-size:24px;font-weight:800;letter-spacing:.2em;color:#92400e;
background:#fef3c7;border:2px dashed #f59e0b;border-radius:8px;
padding:10px 20px;font-family:monospace;user-select:all;white-space:nowrap;">
{{ $confirmCode }}
</div>
</div>
<div style="flex:1;min-width:0;">
<label class="fl" style="color:#92400e;">Paste code here</label>
<input type="text" name="confirm_code" id="confirm-input"
autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false"
placeholder="Paste the code above"
oninput="checkReady()"
style="padding:11px 12px;border:1.5px solid #fde68a;
font-size:16px;font-weight:700;letter-spacing:.12em;font-family:monospace;
text-transform:uppercase;outline:none;background:#fff;">
@error('confirm_code')
<div style="font-size:12px;color:#dc2626;margin-top:4px;">{{ $message }}</div>
@enderror
</div>
</div>
</div>
<button class="btn" type="submit" id="submit-btn" disabled style="opacity:.4;cursor:not-allowed;">
Submit My Quote
</button>
</form> </form>
<p style="font-size:11px;color:#cbd5e1;margin-top:16px;text-align:center;"> <p style="font-size:11px;color:#cbd5e1;margin-top:18px;text-align:center;">
Link expires {{ $invitation->expires_at->format('d M Y') }} · One submission only Link expires {{ $invitation->expires_at->format('d M Y') }} · One submission only
</p> </p>
</div> </div>
@ -126,6 +216,20 @@ function calcRow(i, qty) {
var ge = document.getElementById('grand-total'); var ge = document.getElementById('grand-total');
if (ge) ge.textContent = 'BD ' + grand.toFixed(3); if (ge) ge.textContent = 'BD ' + grand.toFixed(3);
} }
var _expected = '{{ $confirmCode }}';
function checkReady() {
var terms = document.getElementById('terms-cb');
var inp = document.getElementById('confirm-input');
var btn = document.getElementById('submit-btn');
if (!terms || !inp || !btn) return;
var codeOk = inp.value.trim().toUpperCase() === _expected;
var ok = terms.checked && codeOk;
btn.disabled = !ok;
btn.style.opacity = ok ? '1' : '.4';
btn.style.cursor = ok ? 'pointer' : 'not-allowed';
inp.style.borderColor = inp.value.length > 0 ? (codeOk ? '#16a34a' : '#ef4444') : '#fde68a';
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -3,25 +3,89 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quote Submitted</title> <title>Quote Submitted Thank You</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0;} *{box-sizing:border-box;margin:0;padding:0;}
body{font-family:system-ui,-apple-system,sans-serif;background:#f0fdf4;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;} body{font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}
.card{background:#fff;border-radius:20px;box-shadow:0 8px 32px rgba(0,0,0,.10);padding:52px 44px;max-width:480px;width:100%;text-align:center;} @keyframes popIn{from{opacity:0;transform:scale(.88) translateY(18px);}to{opacity:1;transform:scale(1) translateY(0);}}
@keyframes checkDraw{from{stroke-dashoffset:60;}to{stroke-dashoffset:0;}}
@keyframes ringPulse{0%,100%{transform:scale(1);opacity:.3;}50%{transform:scale(1.2);opacity:.1;}}
.detail-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;padding:8px 0;}
/* ── Desktop: centered card ── */
@media(min-width:641px){
body{background:linear-gradient(135deg,#f0fdf4 0%,#ecfdf5 50%,#d1fae5 100%);display:flex;align-items:center;justify-content:center;padding:28px 16px;}
.card{background:#fff;border-radius:24px;box-shadow:0 20px 60px rgba(0,0,0,.10),0 4px 16px rgba(0,0,0,.06);max-width:500px;width:100%;overflow:hidden;animation:popIn .5s cubic-bezier(.34,1.56,.64,1) forwards;}
.inner{padding:44px 40px 36px;text-align:center;}
.footer-bar{padding:14px 40px;background:#f8fafc;border-top:1px solid #f1f5f9;text-align:center;}
}
/* ── Mobile: full page ── */
@media(max-width:640px){
body{background:#fff;display:flex;flex-direction:column;}
.card{background:#fff;border-radius:0;box-shadow:none;width:100%;flex:1;display:flex;flex-direction:column;}
.inner{padding:36px 20px 28px;text-align:center;flex:1;}
.footer-bar{padding:14px 20px;background:#f8fafc;border-top:1px solid #f1f5f9;text-align:center;}
.icon-wrap{margin-bottom:20px !important;}
}
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<div style="font-size:60px;margin-bottom:20px;"></div>
<div style="font-size:24px;font-weight:700;color:#15803d;margin-bottom:10px;">Quote Submitted!</div> <div style="height:6px;background:linear-gradient(90deg,#16a34a,#22c55e,#4ade80);"></div>
<div style="font-size:14px;color:#475569;line-height:1.65;">
Thank you, <strong>{{ $invitation->supplier->name }}</strong>. Your quote for <div class="inner">
<strong>{{ $invitation->purchaseRequest->request_number }}</strong> has been received
and is under review. You will be contacted if you are selected. {{-- Animated check --}}
<div class="icon-wrap" style="position:relative;display:inline-block;margin-bottom:28px;">
<div style="position:absolute;inset:-12px;border-radius:50%;background:#dcfce7;animation:ringPulse 2.5s ease-in-out infinite;"></div>
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#16a34a,#22c55e);display:flex;align-items:center;justify-content:center;position:relative;box-shadow:0 8px 24px rgba(22,163,74,.3);">
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
<path d="M10 19.5L16 25.5L28 13" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
style="stroke-dasharray:60;stroke-dashoffset:60;animation:checkDraw .6s .35s ease forwards;"/>
</svg>
</div> </div>
<div style="margin-top:28px;padding:14px;background:#f0fdf4;border-radius:10px;font-size:12px;color:#64748b;">
Submitted on {{ now()->format('d M Y, H:i') }}
</div> </div>
<div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#16a34a;margin-bottom:8px;">Quote Received</div>
<h1 style="font-size:26px;font-weight:800;color:#0f172a;margin-bottom:14px;line-height:1.25;">
Thank you,<br>{{ $invitation->supplier->name }}!
</h1>
<p style="font-size:15px;color:#475569;line-height:1.75;max-width:360px;margin:0 auto 24px;">
Your quote for <strong style="color:#0f172a;">{{ $invitation->purchaseRequest->request_number }}</strong>
has been successfully received. Our team will review all submitted quotes and get back to you shortly.
</p>
{{-- Details --}}
<div style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;padding:4px 20px;text-align:left;margin-bottom:24px;">
<div class="detail-row">
<span style="color:#64748b;">Reference</span>
<span style="font-weight:700;color:#0f172a;">{{ $invitation->purchaseRequest->request_number }}</span>
</div>
<div class="detail-row" style="border-top:1px solid #f1f5f9;">
<span style="color:#64748b;">Submitted</span>
<span style="font-weight:600;color:#0f172a;">{{ now()->format('d M Y, H:i') }}</span>
</div>
@if($invitation->purchaseRequest->project_name)
<div class="detail-row" style="border-top:1px solid #f1f5f9;">
<span style="color:#64748b;">Project</span>
<span style="font-weight:600;color:#0f172a;">{{ $invitation->purchaseRequest->project_name }}</span>
</div>
@endif
</div>
<p style="font-size:13px;color:#94a3b8;line-height:1.65;">
This link has now been closed. No further action is required.<br>
We appreciate your time and look forward to working with you.
</p>
</div>
<div class="footer-bar">
<span style="font-size:11px;color:#cbd5e1;font-weight:500;letter-spacing:.03em;">SteelERP · Procurement Portal</span>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -369,9 +369,10 @@ $allDeptsJson = json_encode($allDeptsData);
<div style="margin-top:1rem;background:#f0f9ff;border:1px solid #bae6fd;border-radius:0.5rem;padding:0.75rem 1rem;"> <div style="margin-top:1rem;background:#f0f9ff;border:1px solid #bae6fd;border-radius:0.5rem;padding:0.75rem 1rem;">
<p style="font-size:12px;color:#0369a1;margin:0;line-height:1.7;"> <p style="font-size:12px;color:#0369a1;margin:0;line-height:1.7;">
<strong>Expected format (2 tabs):</strong><br> <strong>Expected format (2 tabs):</strong><br>
<strong>Projects</strong> tab columns: <em>Company Name</em> | <em>Project Name</em><br> <strong>Projects</strong> tab <em>Company Name</em> | <em>Project Name</em> | <em>Location Name</em> | <em>Address</em> | <em>Latitude</em> | <em>Longitude</em><br>
<strong>Departments</strong> tab columns: <em>Company Name</em> | <em>Department Name</em><br> &nbsp;&nbsp;&nbsp; Leave Location Name blank for project-only rows. Add multiple rows per project for multiple locations.<br>
Companies are created automatically if they don't exist. Duplicates are skipped. <strong>Departments</strong> tab <em>Company Name</em> | <em>Department Name</em><br>
Companies are created automatically. Address, Latitude, Longitude are optional. Duplicates are skipped.
</p> </p>
</div> </div>
</div> </div>

View File

@ -43,6 +43,20 @@ Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.s
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); 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::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');