feat: RFQ portal, notifications, and project settings updates
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
72e6c3170e
commit
dca9cd5d99
15 files changed
+1095
-140
No files matched your search
@@ -36,6 +36,9 @@ class RfqController extends Controller
|
||||
}
|
||||
|
||||
if (empty($supplierItems)) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Please assign at least one supplier to an item.'], 422);
|
||||
}
|
||||
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
|
||||
}
|
||||
|
||||
@@ -67,6 +70,22 @@ class RfqController extends Controller
|
||||
|
||||
$stages->setStage($purchaseRequest, 'rfq');
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
$invitations = $purchaseRequest->rfqInvitations()->with('supplier')->get()->map(function ($inv) {
|
||||
return [
|
||||
'supplier_name' => $inv->supplier->name,
|
||||
'channel' => $inv->channel,
|
||||
'url' => route('rfq.show', $inv->token),
|
||||
'status' => $inv->status,
|
||||
];
|
||||
});
|
||||
return response()->json([
|
||||
'added' => $added,
|
||||
'invitations' => $invitations,
|
||||
'redirect' => route('purchase.pipeline.show', $purchaseRequest),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Models\SupplierQuoteItem;
|
||||
use App\Models\User;
|
||||
use App\Notifications\QuoteReceived;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -35,9 +37,16 @@ class RfqPortalController extends Controller
|
||||
}
|
||||
|
||||
$purchaseRequest = $invitation->purchaseRequest;
|
||||
$items = $purchaseRequest->items;
|
||||
$itemIds = $invitation->item_ids;
|
||||
$items = $itemIds
|
||||
? $purchaseRequest->items->whereIn('id', $itemIds)->values()
|
||||
: $purchaseRequest->items;
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
|
||||
// Generate a fresh confirmation code per page load and store in session
|
||||
$confirmCode = strtoupper(substr(bin2hex(random_bytes(3)), 0, 5));
|
||||
session(['rfq_confirm_' . $token => $confirmCode]);
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $token)
|
||||
@@ -49,6 +58,8 @@ class RfqPortalController extends Controller
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'terms' => ['accepted'],
|
||||
'confirm_code' => ['required', 'string'],
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
@@ -56,7 +67,16 @@ class RfqPortalController extends Controller
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
]);
|
||||
|
||||
$purchaseItems = $invitation->purchaseRequest->items;
|
||||
$expectedCode = session('rfq_confirm_' . $token);
|
||||
if (!$expectedCode || strtoupper(trim($validated['confirm_code'])) !== $expectedCode) {
|
||||
return back()->withErrors(['confirm_code' => 'Incorrect confirmation code. Please copy the code exactly as shown.'])->withInput();
|
||||
}
|
||||
session()->forget('rfq_confirm_' . $token);
|
||||
|
||||
$itemIds = $invitation->item_ids;
|
||||
$purchaseItems = $itemIds
|
||||
? $invitation->purchaseRequest->items->whereIn('id', $itemIds)->values()
|
||||
: $invitation->purchaseRequest->items;
|
||||
|
||||
$quote = SupplierQuote::create([
|
||||
'rfq_invitation_id' => $invitation->id,
|
||||
@@ -72,7 +92,7 @@ class RfqPortalController extends Controller
|
||||
$total = 0;
|
||||
foreach ($purchaseItems as $i => $item) {
|
||||
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity;
|
||||
$qty = (float)$item->quantity_required;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$total += $totalPrice;
|
||||
|
||||
@@ -95,6 +115,10 @@ class RfqPortalController extends Controller
|
||||
app(PurchaseStageService::class)->setStage($pr, 'comparison');
|
||||
}
|
||||
|
||||
// Notify all admin users
|
||||
$invitation->load('supplier', 'purchaseRequest');
|
||||
User::role('Admin')->each(fn($u) => $u->notify(new QuoteReceived($invitation)));
|
||||
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ class ProjectSettingController extends Controller
|
||||
|
||||
$parts = [];
|
||||
if ($stats['projects_created']) $parts[] = "{$stats['projects_created']} project(s)";
|
||||
if ($stats['locations_created']) $parts[] = "{$stats['locations_created']} location(s)";
|
||||
if ($stats['departments_created']) $parts[] = "{$stats['departments_created']} department(s)";
|
||||
if ($stats['companies_created']) $parts[] = "{$stats['companies_created']} new company(s)";
|
||||
|
||||
@@ -220,29 +221,41 @@ class ProjectSettingController extends Controller
|
||||
'font' => ['italic' => true, 'color' => ['rgb' => '64748b'], 'size' => 10],
|
||||
];
|
||||
|
||||
// ── Sheet 1: Projects ──────────────────────────────────────────────
|
||||
// ── Sheet 1: Projects (with optional locations) ────────────────────
|
||||
$s1 = $spreadsheet->getActiveSheet()->setTitle('Projects');
|
||||
$s1->setCellValue('A1', 'Company Name')
|
||||
->setCellValue('B1', 'Project Name');
|
||||
$s1->getStyle('A1:B1')->applyFromArray($headerStyle);
|
||||
->setCellValue('B1', 'Project Name')
|
||||
->setCellValue('C1', 'Location Name')
|
||||
->setCellValue('D1', 'Address')
|
||||
->setCellValue('E1', 'Latitude')
|
||||
->setCellValue('F1', 'Longitude');
|
||||
$s1->getStyle('A1:F1')->applyFromArray($headerStyle);
|
||||
|
||||
// Sample rows
|
||||
$samples = [
|
||||
['Miknas Industrial', 'New Warehouse'],
|
||||
['Steel tech', 'Factory Extension'],
|
||||
['Steel tech', 'New Office Block'],
|
||||
['Miknas Industrial', 'New Warehouse', 'Main Gate', 'Industrial Area, Block 5', '24.7136', '46.6753'],
|
||||
['Miknas Industrial', 'New Warehouse', 'Storage Yard', '', '', ''],
|
||||
['Steel tech', 'Factory Extension','Site Office', '2nd Ring Road, Riyadh', '24.6877', '46.7219'],
|
||||
['Steel tech', 'New Office Block', '', '', '', ''],
|
||||
];
|
||||
foreach ($samples as $i => $row) {
|
||||
$s1->setCellValue('A' . ($i + 2), $row[0]);
|
||||
$s1->setCellValue('B' . ($i + 2), $row[1]);
|
||||
$s1->setCellValue('C' . ($i + 2), $row[2]);
|
||||
$s1->setCellValue('D' . ($i + 2), $row[3]);
|
||||
$s1->setCellValue('E' . ($i + 2), $row[4]);
|
||||
$s1->setCellValue('F' . ($i + 2), $row[5]);
|
||||
}
|
||||
|
||||
$s1->setCellValue('A6', '* Delete sample rows before importing. Company will be created if it does not exist.');
|
||||
$s1->getStyle('A6')->applyFromArray($noteStyle);
|
||||
$s1->mergeCells('A6:B6');
|
||||
$s1->setCellValue('A7', '* Leave Location Name blank for rows that are projects only. Company is created automatically. Duplicates are skipped.');
|
||||
$s1->getStyle('A7')->applyFromArray($noteStyle);
|
||||
$s1->mergeCells('A7:F7');
|
||||
|
||||
$s1->getColumnDimension('A')->setWidth(32);
|
||||
$s1->getColumnDimension('B')->setWidth(32);
|
||||
$s1->getColumnDimension('A')->setWidth(28);
|
||||
$s1->getColumnDimension('B')->setWidth(28);
|
||||
$s1->getColumnDimension('C')->setWidth(24);
|
||||
$s1->getColumnDimension('D')->setWidth(32);
|
||||
$s1->getColumnDimension('E')->setWidth(14);
|
||||
$s1->getColumnDimension('F')->setWidth(14);
|
||||
|
||||
// ── Sheet 2: Departments ───────────────────────────────────────────
|
||||
$s2 = new Worksheet($spreadsheet, 'Departments');
|
||||
|
||||
+4
-1
@@ -41,8 +41,11 @@ class User extends Authenticatable
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class ProjectImportService
|
||||
private array $stats = [
|
||||
'companies_created' => 0,
|
||||
'projects_created' => 0,
|
||||
'locations_created' => 0,
|
||||
'departments_created' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
@@ -56,8 +57,12 @@ class ProjectImportService
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
$headers = $this->normalizeHeaders((array) array_shift($rows));
|
||||
|
||||
$coIdx = $this->findCol($headers, ['company', 'company name', 'company name', 'companyname']);
|
||||
$projIdx = $this->findCol($headers, ['project', 'project name', 'project name', 'projectname']);
|
||||
$coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']);
|
||||
$projIdx = $this->findCol($headers, ['project', 'project name', 'projectname']);
|
||||
$locIdx = $this->findCol($headers, ['location', 'location name', 'locationname', 'loc', 'loc name']);
|
||||
$addrIdx = $this->findCol($headers, ['address', 'addr']);
|
||||
$latIdx = $this->findCol($headers, ['latitude', 'lat']);
|
||||
$lngIdx = $this->findCol($headers, ['longitude', 'lng', 'lon', 'long']);
|
||||
|
||||
if ($coIdx === null || $projIdx === null) {
|
||||
return;
|
||||
@@ -72,17 +77,38 @@ class ProjectImportService
|
||||
continue;
|
||||
}
|
||||
|
||||
$company = $this->findOrCreateCompany($coName);
|
||||
$existing = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
|
||||
$company = $this->findOrCreateCompany($coName);
|
||||
$project = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
|
||||
->where('company_id', $company->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->stats['skipped']++;
|
||||
continue;
|
||||
if (!$project) {
|
||||
$project = ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
|
||||
$this->stats['projects_created']++;
|
||||
}
|
||||
|
||||
ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
|
||||
$this->stats['projects_created']++;
|
||||
// If a location name is present on this row, import it too
|
||||
$locName = $locIdx !== null ? $this->str($row[$locIdx] ?? null) : null;
|
||||
if ($locName) {
|
||||
$existingLoc = $project->locations()
|
||||
->whereRaw('LOWER(name) = ?', [strtolower($locName)])->first();
|
||||
|
||||
if ($existingLoc) {
|
||||
$this->stats['skipped']++;
|
||||
} else {
|
||||
$address = $addrIdx !== null ? $this->str($row[$addrIdx] ?? null) : null;
|
||||
$lat = $latIdx !== null ? $this->str($row[$latIdx] ?? null) : null;
|
||||
$lng = $lngIdx !== null ? $this->str($row[$lngIdx] ?? null) : null;
|
||||
|
||||
$project->locations()->create([
|
||||
'name' => $locName,
|
||||
'address' => $address,
|
||||
'latitude' => is_numeric($lat) ? (float) $lat : null,
|
||||
'longitude' => is_numeric($lng) ? (float) $lng : null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->stats['locations_created']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user