feat: quote detail modal, SteelERP favicon, and notification navigation

- Pipeline page: clicking a supplier quote card opens a modal with full
  line-item breakdown, lead time, payment terms, notes, awarded status,
  and a Compare All Quotes button
- Eager-load supplierQuotes.supplier and supplierQuotes.items in pipeline
  controller to avoid N+1 on the modal data
- Browser tab now shows SteelERP first (SteelERP — Page Name)
- Added SVG favicon matching the sidebar blue-square logo
- Notification clicks now navigate to the relevant page via a dedicated
  /notifications/{id}/go route that marks only that notification as read

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ghassan Yusuf 2026-06-01 17:39:04 +03:00
parent 30b2fb3958
commit 9c4c752800
6 changed files with 244 additions and 15 deletions

View File

@ -35,7 +35,8 @@ class PurchasePipelineController extends Controller
'items', 'items',
'signature.signedBy', 'signature.signedBy',
'rfqInvitations.supplier', 'rfqInvitations.supplier',
'supplierQuotes', 'supplierQuotes.supplier',
'supplierQuotes.items',
'awardedQuote.supplier', 'awardedQuote.supplier',
]); ]);

5
public/favicon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a"/>
<rect x="4" y="4" width="24" height="24" rx="5" fill="#2563eb"/>
<text x="16" y="23" font-family="Arial Black, Arial, sans-serif" font-weight="900" font-size="18" fill="#ffffff" text-anchor="middle" letter-spacing="-1">S</text>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -4,7 +4,9 @@
<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">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Dashboard') SteelERP</title> <title>SteelERP @hasSection('title') @yield('title') @endif</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet"/> <link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet"/>
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@ -561,7 +563,7 @@ document.addEventListener('keydown', function(e) {
empty.style.display = 'none'; empty.style.display = 'none';
var html = ''; var html = '';
data.items.forEach(function(n) { 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 += '<a href="' + n.go_url + '" 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: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 += '<div style="font-size:11px;color:#94a3b8;margin-top:2px;">' + n.ago + '</div>';
html += '</a>'; html += '</a>';

View File

@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title> <title>SteelERP</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="/favicon.ico">
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">

View File

@ -300,23 +300,39 @@
Quotes ({{ $pr->supplierQuotes->count() }}) Quotes ({{ $pr->supplierQuotes->count() }})
</h3> </h3>
<div style="display:flex;flex-direction:column;gap:8px;"> <div style="display:flex;flex-direction:column;gap:8px;">
@php $minTotal = $pr->supplierQuotes->min('total_amount'); @endphp
@foreach($pr->supplierQuotes->sortBy('total_amount') as $quote) @foreach($pr->supplierQuotes->sortBy('total_amount') as $quote)
<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:8px 10px;border-radius:8px; @php $isLowest = !$quote->is_awarded && $pr->supplierQuotes->count() > 1 && (float)$quote->total_amount === (float)$minTotal; @endphp
background:{{ $quote->is_awarded ? '#f0fdf4' : '#f8fafc' }}; <div onclick="openQuoteModal({{ $quote->id }})"
border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : '#f1f5f9' }};"> style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:box-shadow .15s,border-color .15s;
background:{{ $quote->is_awarded ? '#f0fdf4' : ($isLowest ? '#eff6ff' : '#f8fafc') }};
border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }};"
onmouseenter="this.style.boxShadow='0 2px 8px rgba(0,0,0,.08)';this.style.borderColor='{{ $quote->is_awarded ? '#86efac' : ($isLowest ? '#93c5fd' : '#e2e8f0') }}';"
onmouseleave="this.style.boxShadow='';this.style.borderColor='{{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }}';">
<div>
<div style="font-weight:600;color:#0f172a;">{{ $quote->supplier->name }}</div> <div style="font-weight:600;color:#0f172a;">{{ $quote->supplier->name }}</div>
<div style="color:{{ $quote->is_awarded ? '#15803d' : '#374151' }};font-weight:700;"> @if($isLowest)
{{ number_format($quote->total_amount, 2) }} <div style="font-size:10px;color:#2563eb;font-weight:700;margin-top:1px;">LOWEST</div>
@endif
</div>
<div style="display:flex;align-items:center;gap:6px;">
<div style="color:{{ $quote->is_awarded ? '#15803d' : ($isLowest ? '#2563eb' : '#374151') }};font-weight:700;">
BD {{ number_format($quote->total_amount, 3) }}
</div>
@if($quote->is_awarded) @if($quote->is_awarded)
<span style="font-size:10px;background:#22c55e;color:#fff;padding:1px 6px;border-radius:10px;margin-left:4px;">Awarded</span> <span style="font-size:10px;background:#22c55e;color:#fff;padding:1px 6px;border-radius:10px;"></span>
@else
<svg width="12" height="12" fill="none" stroke="#94a3b8" stroke-width="2" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
@endif @endif
</div> </div>
</div> </div>
@endforeach @endforeach
</div> </div>
<a href="{{ route('purchase.requests.quotes', $pr) }}" <a href="{{ route('purchase.requests.compare', $pr) }}"
style="display:block;margin-top:12px;text-align:center;font-size:12px;color:#2563eb;text-decoration:none;font-weight:600;"> style="display:block;margin-top:12px;text-align:center;font-size:12px;color:#f59e0b;text-decoration:none;font-weight:700;padding:7px;border:1.5px solid #fde68a;border-radius:8px;background:#fffbeb;">
View All Quotes Compare All Quotes
</a> </a>
</div> </div>
@endif @endif
@ -401,6 +417,202 @@
<x-purchase.supplier-select-modal :pr="$pr" :suppliers="$suppliers" :selectedIds="$selectedIds" /> <x-purchase.supplier-select-modal :pr="$pr" :suppliers="$suppliers" :selectedIds="$selectedIds" />
{{-- ============================================================
QUOTE DETAIL MODAL
============================================================ --}}
<div id="quote-modal" class="pipe-modal" role="dialog" aria-modal="true" onclick="if(event.target===this)closeQuoteModal()">
<div style="background:#fff;border-radius:20px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 30px 60px rgba(0,0,0,.25);overflow:hidden;">
{{-- Header --}}
<div id="qm-header" style="background:linear-gradient(135deg,#f59e0b,#d97706);padding:20px 24px;display:flex;align-items:flex-start;justify-content:space-between;flex-shrink:0;">
<div>
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Supplier Quote</div>
<div id="qm-supplier" style="font-size:19px;font-weight:700;color:#fff;margin-top:4px;"></div>
<div id="qm-submitted" style="font-size:12px;color:rgba(255,255,255,.75);margin-top:2px;"></div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="text-align:right;">
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);">Total</div>
<div id="qm-total" style="font-size:22px;font-weight:800;color:#fff;"></div>
</div>
<button onclick="closeQuoteModal()"
style="width:32px;height:32px;border-radius:8px;border:none;background:rgba(255,255,255,.2);cursor:pointer;font-size:18px;color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;">×</button>
</div>
</div>
{{-- Body --}}
<div style="overflow-y:auto;flex:1;">
{{-- Meta row --}}
<div id="qm-meta" style="display:flex;gap:0;border-bottom:1px solid #f1f5f9;"></div>
{{-- Items table --}}
<div style="padding:20px 24px 0;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Line Items</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:12.5px;" id="qm-items-table">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:8px 10px;text-align:left;color:#64748b;font-weight:600;border-radius:6px 0 0 6px;">Item</th>
<th style="padding:8px 10px;text-align:right;color:#64748b;font-weight:600;white-space:nowrap;">Qty</th>
<th style="padding:8px 10px;text-align:right;color:#64748b;font-weight:600;white-space:nowrap;">Unit Price</th>
<th style="padding:8px 10px;text-align:right;color:#64748b;font-weight:600;border-radius:0 6px 6px 0;white-space:nowrap;">Total</th>
</tr>
</thead>
<tbody id="qm-items"></tbody>
</table>
</div>
</div>
{{-- Notes --}}
<div id="qm-notes-wrap" style="margin:14px 24px 0;padding:12px 14px;background:#f8fafc;border-radius:10px;border:1px solid #f1f5f9;display:none;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;">Notes</div>
<div id="qm-notes" style="font-size:13px;color:#374151;"></div>
</div>
{{-- Awarded banner --}}
<div id="qm-awarded-banner" style="margin:14px 24px 0;padding:10px 14px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;font-size:13px;font-weight:700;color:#15803d;display:none;">
This quote was awarded
</div>
<div style="height:20px;"></div>
</div>
{{-- Footer --}}
<div style="padding:16px 24px;border-top:1px solid #f1f5f9;display:flex;gap:10px;flex-shrink:0;background:#fff;">
<button onclick="closeQuoteModal()"
style="flex:1;padding:10px;border:1.5px solid #e2e8f0;border-radius:9px;font-size:13px;font-weight:600;color:#475569;background:#f8fafc;cursor:pointer;">
Close
</button>
<a id="qm-compare-btn" href="{{ route('purchase.requests.compare', $pr) }}"
style="flex:2;padding:10px;background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;text-decoration:none;display:flex;align-items:center;justify-content:center;gap:6px;">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Compare All Quotes
</a>
</div>
</div>
</div>
@php
$quoteDataForJs = $pr->supplierQuotes->keyBy('id')->map(function($q) {
return [
'id' => $q->id,
'supplier' => $q->supplier->name,
'total' => number_format($q->total_amount, 3),
'isAwarded' => (bool)$q->is_awarded,
'leadTime' => $q->lead_time_days !== null ? $q->lead_time_days . ' days' : null,
'paymentTerms'=> $q->payment_terms,
'notes' => $q->notes,
'submittedAt' => $q->submitted_at->format('d M Y, H:i'),
'items' => $q->items->map(function($i) {
return [
'description' => $i->description,
'supplierDescription' => $i->supplier_description,
'quantity' => $i->quantity,
'unit' => $i->unit,
'unitPrice' => number_format($i->unit_price, 3),
'totalPrice' => number_format($i->total_price, 3),
'isVatable' => (bool)$i->is_vatable,
'notAvailable' => (bool)$i->not_available,
];
})->values(),
];
});
@endphp
<script>
var QUOTE_DATA = @json($quoteDataForJs);
</script>
<script>
// ---- Quote detail modal ----
(function () {
window.openQuoteModal = function (id) {
var q = QUOTE_DATA[id];
if (!q) return;
// Header
document.getElementById('qm-supplier').textContent = q.supplier;
document.getElementById('qm-total').textContent = 'BD ' + q.total;
document.getElementById('qm-submitted').textContent = 'Submitted ' + q.submittedAt;
// Awarded header tint
var hdr = document.getElementById('qm-header');
hdr.style.background = q.isAwarded
? 'linear-gradient(135deg,#16a34a,#15803d)'
: 'linear-gradient(135deg,#f59e0b,#d97706)';
// Meta chips
var meta = document.getElementById('qm-meta');
meta.innerHTML = '';
var chips = [];
if (q.leadTime) chips.push(['⏱ Lead Time', q.leadTime]);
if (q.paymentTerms) chips.push(['💳 Payment', q.paymentTerms]);
chips.forEach(function (c) {
var d = document.createElement('div');
d.style.cssText = 'flex:1;padding:10px 16px;border-right:1px solid #f1f5f9;';
d.innerHTML = '<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;margin-bottom:2px;">' + c[0] + '</div>'
+ '<div style="font-size:13px;font-weight:600;color:#0f172a;">' + c[1] + '</div>';
meta.appendChild(d);
});
// Items
var tbody = document.getElementById('qm-items');
tbody.innerHTML = '';
q.items.forEach(function (item, i) {
var tr = document.createElement('tr');
tr.style.borderTop = i > 0 ? '1px solid #f8fafc' : '';
if (item.notAvailable) {
tr.innerHTML =
'<td style="padding:8px 10px;color:#0f172a;font-weight:500;">' +
escHtml(item.description) +
(item.supplierDescription ? '<div style="font-size:11px;color:#94a3b8;font-style:italic;margin-top:2px;">' + escHtml(item.supplierDescription) + '</div>' : '') +
'</td>' +
'<td style="padding:8px 10px;text-align:right;color:#94a3b8;">' + item.quantity + '</td>' +
'<td colspan="2" style="padding:8px 10px;text-align:center;">' +
'<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 8px;border-radius:4px;border:1px solid #fecaca;">N/A</span>' +
'</td>';
} else {
tr.innerHTML =
'<td style="padding:8px 10px;color:#0f172a;font-weight:500;">' +
escHtml(item.description) +
(item.supplierDescription ? '<div style="font-size:11px;color:#64748b;font-style:italic;margin-top:2px;">' + escHtml(item.supplierDescription) + ' <span style="background:#fef3c7;color:#92400e;font-size:9px;font-weight:700;padding:1px 4px;border-radius:3px;border:1px solid #fde68a;font-style:normal;">adjusted</span></div>' : '') +
'</td>' +
'<td style="padding:8px 10px;text-align:right;color:#64748b;">' + item.quantity + (item.unit ? ' ' + escHtml(item.unit) : '') + '</td>' +
'<td style="padding:8px 10px;text-align:right;font-weight:600;color:#0f172a;">' +
'BD ' + item.unitPrice +
(item.isVatable ? ' <span title="VAT applicable" style="font-size:9px;font-weight:700;color:#0ea5e9;background:#e0f2fe;padding:1px 4px;border-radius:3px;border:1px solid #bae6fd;">VAT</span>' : '') +
'</td>' +
'<td style="padding:8px 10px;text-align:right;font-weight:700;color:#0f172a;">BD ' + item.totalPrice + '</td>';
}
tbody.appendChild(tr);
});
// Notes
var nw = document.getElementById('qm-notes-wrap');
if (q.notes) {
document.getElementById('qm-notes').textContent = q.notes;
nw.style.display = 'block';
} else {
nw.style.display = 'none';
}
// Awarded banner
document.getElementById('qm-awarded-banner').style.display = q.isAwarded ? 'block' : 'none';
document.getElementById('quote-modal').classList.add('open');
};
window.closeQuoteModal = function () {
document.getElementById('quote-modal').classList.remove('open');
};
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
}());
</script>
<script> <script>
// ---- Signature modal ---- // ---- Signature modal ----

View File

@ -49,11 +49,18 @@ Route::middleware(['auth', 'verified'])->group(function () {
'items' => auth()->user()->unreadNotifications()->latest()->take(10)->get()->map(fn($n) => [ 'items' => auth()->user()->unreadNotifications()->latest()->take(10)->get()->map(fn($n) => [
'id' => $n->id, 'id' => $n->id,
'message' => $n->data['message'] ?? '', 'message' => $n->data['message'] ?? '',
'url' => $n->data['url'] ?? null, 'go_url' => route('notifications.go', $n->id),
'ago' => $n->created_at->diffForHumans(), 'ago' => $n->created_at->diffForHumans(),
]), ]),
]))->name('notifications.unread'); ]))->name('notifications.unread');
Route::get('/notifications/{id}/go', function (string $id) {
$n = auth()->user()->notifications()->findOrFail($id);
$n->markAsRead();
$dest = $n->data['url'] ?? route('dashboard');
return redirect($dest);
})->name('notifications.go');
Route::post('/notifications/read-all', fn() => response()->json( Route::post('/notifications/read-all', fn() => response()->json(
tap(auth()->user()->unreadNotifications()->update(['read_at' => now()])) tap(auth()->user()->unreadNotifications()->update(['read_at' => now()]))
))->name('notifications.read-all'); ))->name('notifications.read-all');