Compare commits

...
13 Commits
Author SHA1 Message Date
ghassanandClaude Sonnet 4.6 9c4c752800 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 <[email protected]>
2026-06-01 17:39:04 +03:00
ghassan 30b2fb3958 fix: move local packages inside repo so remote server can install them 2026-06-01 13:48:08 +03:00
ghassan 50e5f3e381 fix: move local packages inside repo so remote server can install them 2026-06-01 13:47:29 +03:00
ghassan d28e7330e6 fix: wider RFQ table, more padding, subtle blue VAT row 2026-06-01 12:37:34 +03:00
ghassan 716f0afcac fix: VAT row same color as other footer rows 2026-06-01 12:24:18 +03:00
ghassan 6061e8ca4f feat: supplier item name editing, N/A checkbox, and adjusted indicator on compare view 2026-06-01 12:19:52 +03:00
ghassan 7b399d5167 feat: add VAT checkbox column and live breakdown to RFQ portal 2026-06-01 12:11:54 +03:00
ghassan ee89269c0b feat: load VAT rate and store is_vatable in RFQ portal 2026-06-01 12:11:02 +03:00
ghassan a9db089a90 feat: add VAT Settings link to sidebar 2026-06-01 12:10:27 +03:00
ghassan 04ced4d2da feat: add VAT settings page and controller 2026-06-01 12:10:01 +03:00
ghassan b19396c996 feat: add is_vatable column to supplier_quote_items 2026-06-01 12:09:13 +03:00
ghassan 3252cf7fb8 docs: add VAT + RFQ vatable checkbox design spec 2026-06-01 12:06:37 +03:00
ghassanandClaude Sonnet 4.6 dca9cd5d99 feat: RFQ portal, notifications, and project settings updates
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-01 11:52:21 +03:00
68 changed files with 3957 additions and 199 deletions

No files matched your search

+2
View File
@@ -19,6 +19,8 @@
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
/packages/azure-mailer/vendor
/packages/ultra-message/vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
@@ -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',
]); ]);
@@ -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.');
} }
@@ -4,8 +4,11 @@ namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\RfqInvitation; use App\Models\RfqInvitation;
use App\Models\Setting;
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 +38,18 @@ 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]);
$vatRate = (float) Setting::get('vat_rate', 0);
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
} }
public function submit(Request $request, string $token) public function submit(Request $request, string $token)
@@ -49,14 +61,28 @@ class RfqPortalController extends Controller
} }
$validated = $request->validate([ $validated = $request->validate([
'lead_time_days' => ['nullable', 'integer', 'min:0'], 'terms' => ['accepted'],
'payment_terms' => ['nullable', 'string', 'max:200'], 'confirm_code' => ['required', 'string'],
'notes' => ['nullable', 'string', 'max:1000'], 'lead_time_days' => ['nullable', 'integer', 'min:0'],
'items' => ['required', 'array'], 'payment_terms' => ['nullable', 'string', 'max:200'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'], 'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array'],
'items.*.unit_price' => ['nullable', 'numeric', 'min:0'],
'items.*.is_vatable' => ['nullable', 'boolean'],
'items.*.not_available' => ['nullable', 'boolean'],
'items.*.supplier_description' => ['nullable', 'string', 'max:500'],
]); ]);
$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,
@@ -69,24 +95,40 @@ class RfqPortalController extends Controller
'total_amount' => 0, 'total_amount' => 0,
]); ]);
$total = 0; $subtotal = 0;
$vatAmount = 0;
$vatRate = (float) Setting::get('vat_rate', 0);
foreach ($purchaseItems as $i => $item) { foreach ($purchaseItems as $i => $item) {
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0); $notAvailable = !empty($validated['items'][$i]['not_available']);
$qty = (float)$item->quantity; $unitPrice = $notAvailable ? 0 : (float)($validated['items'][$i]['unit_price'] ?? 0);
$totalPrice = round($unitPrice * $qty, 3); $qty = (float)$item->quantity_required;
$total += $totalPrice; $totalPrice = $notAvailable ? 0 : round($unitPrice * $qty, 3);
$isVatable = !$notAvailable && !empty($validated['items'][$i]['is_vatable']);
$supplierDescription = !empty($validated['items'][$i]['supplier_description'])
? trim($validated['items'][$i]['supplier_description'])
: null;
$subtotal += $totalPrice;
if ($isVatable && $vatRate > 0) {
$vatAmount += round($totalPrice * $vatRate / 100, 3);
}
SupplierQuoteItem::create([ SupplierQuoteItem::create([
'supplier_quote_id' => $quote->id, 'supplier_quote_id' => $quote->id,
'description' => $item->description, 'description' => $item->description,
'unit' => $item->unit ?? '', 'supplier_description'=> $supplierDescription,
'quantity' => $qty, 'unit' => $item->unit ?? '',
'unit_price' => $unitPrice, 'quantity' => $qty,
'total_price' => $totalPrice, 'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'is_vatable' => $isVatable,
'not_available' => $notAvailable,
]); ]);
} }
$quote->update(['total_amount' => round($total, 3)]); $quote->update(['total_amount' => round($subtotal + $vatAmount, 3)]);
$invitation->update(['status' => 'submitted']); $invitation->update(['status' => 'submitted']);
// If at least 1 quote is in, move to comparison stage // If at least 1 quote is in, move to comparison stage
@@ -95,6 +137,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'));
} }
} }
@@ -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');
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
class VatSettingController extends Controller
{
public function index()
{
$vatRate = Setting::get('vat_rate', '0');
return view('settings.vat', compact('vatRate'));
}
public function update(Request $request)
{
$validated = $request->validate([
'vat_rate' => ['required', 'numeric', 'min:0', 'max:100'],
]);
Setting::set('vat_rate', (string) $validated['vat_rate']);
return response()->json(['message' => 'VAT rate saved.', 'vat_rate' => $validated['vat_rate']]);
}
}
+7 -1
View File
@@ -7,7 +7,13 @@ use Illuminate\Database\Eloquent\Model;
class SupplierQuoteItem extends Model class SupplierQuoteItem extends Model
{ {
protected $fillable = [ protected $fillable = [
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price', 'supplier_quote_id', 'description', 'supplier_description', 'unit', 'quantity',
'unit_price', 'total_price', 'is_vatable', 'not_available',
];
protected $casts = [
'is_vatable' => 'boolean',
'not_available' => 'boolean',
]; ];
public function quote() public function quote()
+4 -1
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;
} }
+32
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),
];
}
}
+35 -9
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;
@@ -72,17 +77,38 @@ class ProjectImportService
continue; continue;
} }
$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']++;
}
}
} }
} }
+2 -2
View File
@@ -8,11 +8,11 @@
"repositories": [ "repositories": [
{ {
"type": "path", "type": "path",
"url": "../ultra-message" "url": "./packages/ultra-message"
}, },
{ {
"type": "path", "type": "path",
"url": "../azure-mailer" "url": "./packages/azure-mailer"
} }
], ],
"require": { "require": {
Generated
+4 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5daed94f08a2aff0bba86affd37a0cd6", "content-hash": "644cad003bc58ba3ea106336e06e0396",
"packages": [ "packages": [
{ {
"name": "barryvdh/laravel-dompdf", "name": "barryvdh/laravel-dompdf",
@@ -3283,8 +3283,8 @@
"version": "dev-master", "version": "dev-master",
"dist": { "dist": {
"type": "path", "type": "path",
"url": "../azure-mailer", "url": "./packages/azure-mailer",
"reference": "d240f4f64afcd094b30cd460b82f809f3b2003f9" "reference": "b6d04a3e3a01a8439853efe8f52f672ae8a04b75"
}, },
"require": { "require": {
"illuminate/cache": "^11.0|^12.0", "illuminate/cache": "^11.0|^12.0",
@@ -3334,7 +3334,7 @@
"version": "dev-master", "version": "dev-master",
"dist": { "dist": {
"type": "path", "type": "path",
"url": "../ultra-message", "url": "./packages/ultra-message",
"reference": "56160235808f7d9d9dd3ec8d0eefa20a17807647" "reference": "56160235808f7d9d9dd3ec8d0eefa20a17807647"
}, },
"require": { "require": {
@@ -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');
}
};
@@ -0,0 +1,25 @@
<?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::table('supplier_quote_items', function (Blueprint $table) {
$table->boolean('is_vatable')->default(false)->after('total_price');
});
}
public function down(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->dropColumn('is_vatable');
});
}
};
@@ -0,0 +1,26 @@
<?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::table('supplier_quote_items', function (Blueprint $table) {
$table->string('supplier_description')->nullable()->after('description');
$table->boolean('not_available')->default(false)->after('is_vatable');
});
}
public function down(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->dropColumn(['supplier_description', 'not_available']);
});
}
};
@@ -0,0 +1,440 @@
# VAT Settings + RFQ Per-Item Vatable Checkbox — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a global VAT rate setting and per-item vatable checkboxes on the supplier RFQ portal, with live VAT breakdown in totals.
**Architecture:** Global VAT rate stored in existing `settings` key/value table. Per-item `is_vatable` flag added to `supplier_quote_items`. RFQ portal view updated with checkbox column and three-row footer (subtotal / VAT / grand total). New `VatSettingController` + `settings/vat` page added under the Admin-only System sidebar section.
**Tech Stack:** Laravel 12, PHP 8.2, SQLite, Blade, Alpine-free vanilla JS, Tailwind (inline styles per project convention)
---
### Task 1: Migration — add `is_vatable` to `supplier_quote_items`
**Files:**
- Modify: `database/migrations/2026_06_01_090734_add_is_vatable_to_supplier_quote_items.php`
- Modify: `app/Models/SupplierQuoteItem.php`
- [ ] **Step 1: Write the migration**
Replace the generated migration body with:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->boolean('is_vatable')->default(false)->after('total_price');
});
}
public function down(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->dropColumn('is_vatable');
});
}
};
```
- [ ] **Step 2: Run migration**
```bash
php artisan migrate
```
Expected: `Migrating: 2026_06_01_090734_add_is_vatable_to_supplier_quote_items` then `Migrated`.
- [ ] **Step 3: Update SupplierQuoteItem model**
In `app/Models/SupplierQuoteItem.php`, update `$fillable`:
```php
protected $fillable = [
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price', 'is_vatable',
];
protected $casts = [
'is_vatable' => 'boolean',
];
```
- [ ] **Step 4: Commit**
```bash
git add database/migrations/2026_06_01_090734_add_is_vatable_to_supplier_quote_items.php app/Models/SupplierQuoteItem.php
git commit -m "feat: add is_vatable column to supplier_quote_items"
```
---
### Task 2: VatSettingController + route + view
**Files:**
- Create: `app/Http/Controllers/Settings/VatSettingController.php`
- Create: `resources/views/settings/vat.blade.php`
- Modify: `routes/web.php`
- [ ] **Step 1: Create VatSettingController**
Create `app/Http/Controllers/Settings/VatSettingController.php`:
```php
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
class VatSettingController extends Controller
{
public function index()
{
$vatRate = Setting::get('vat_rate', '0');
return view('settings.vat', compact('vatRate'));
}
public function update(Request $request)
{
$validated = $request->validate([
'vat_rate' => ['required', 'numeric', 'min:0', 'max:100'],
]);
Setting::set('vat_rate', (string) $validated['vat_rate']);
return response()->json(['message' => 'VAT rate saved.', 'vat_rate' => $validated['vat_rate']]);
}
}
```
- [ ] **Step 2: Add routes**
In `routes/web.php`, after the integrations routes (around line 148), add:
```php
// VAT settings
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
```
Also add the import at the top of the file with the other Settings controllers:
```php
use App\Http\Controllers\Settings\VatSettingController;
```
- [ ] **Step 3: Create the VAT settings view**
Create `resources/views/settings/vat.blade.php`:
```blade
@extends('layouts.app')
@section('title', 'Settings — VAT')
@section('content')
<div class="mb-5">
<h1 class="page-title">VAT Settings</h1>
<p class="page-subtitle">Set the global VAT rate applied to vatable items on supplier quotes.</p>
</div>
<div style="max-width:480px;">
<div style="background:white;border:1px solid #e2e8f0;border-radius:0.875rem;overflow:hidden;">
<div style="padding:1.25rem 1.5rem;border-bottom:1px solid #e2e8f0;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;">VAT Configuration</div>
</div>
<div style="padding:1.5rem;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;">
VAT Rate (%)
</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="number" id="vat-rate-input" value="{{ $vatRate }}"
min="0" max="100" step="0.01" placeholder="e.g. 10"
style="width:160px;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;font-weight:600;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
<span style="font-size:14px;color:#64748b;font-weight:500;">%</span>
</div>
<p style="font-size:12px;color:#94a3b8;margin-top:8px;">
Enter 0 to disable VAT. Suppliers will see the VAT checkbox on their quote form when this is greater than 0.
</p>
<button onclick="saveVat()"
style="margin-top:20px;padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">
Save VAT Rate
</button>
</div>
</div>
</div>
<script>
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function saveVat() {
var rate = document.getElementById('vat-rate-input').value;
fetch('{{ route('settings.vat.update') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_rate: rate })
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
}).then(function() {
showToast('VAT rate saved.', 'success');
}).catch(function(err) {
showToast((err.errors && err.errors.vat_rate ? err.errors.vat_rate[0] : null) || 'Failed to save.', 'error');
});
}
</script>
@endsection
```
- [ ] **Step 4: Commit**
```bash
git add app/Http/Controllers/Settings/VatSettingController.php resources/views/settings/vat.blade.php routes/web.php
git commit -m "feat: add VAT settings page and controller"
```
---
### Task 3: Add VAT link to sidebar
**Files:**
- Modify: `resources/views/layouts/app.blade.php`
- [ ] **Step 1: Add sidebar link**
In `resources/views/layouts/app.blade.php`, after the Integrations `<a>` tag (around line 198, just before `@endrole`), add:
```blade
<a href="{{ route('settings.vat') }}" style="
display:flex; align-items:center; gap:8px;
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
font-size:13px; text-decoration:none;
{{ request()->routeIs('settings.vat*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/>
</svg>
VAT Settings
</a>
```
- [ ] **Step 2: Commit**
```bash
git add resources/views/layouts/app.blade.php
git commit -m "feat: add VAT Settings link to sidebar"
```
---
### Task 4: Update RfqPortalController — load VAT rate and store is_vatable
**Files:**
- Modify: `app/Http/Controllers/Purchase/RfqPortalController.php`
- [ ] **Step 1: Update show() to pass VAT rate**
Add `use App\Models\Setting;` at the top of the file.
Update the `show()` method — add before the `return view(...)` line:
```php
$vatRate = (float) Setting::get('vat_rate', 0);
```
Update the return to:
```php
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
```
- [ ] **Step 2: Update submit() to store is_vatable and calculate VAT-inclusive total**
Add `'items.*.is_vatable' => ['nullable', 'boolean']` to the validation rules array.
Replace the items loop and total calculation:
```php
$subtotal = 0;
$vatAmount = 0;
$vatRate = (float) Setting::get('vat_rate', 0);
foreach ($purchaseItems as $i => $item) {
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
$qty = (float)$item->quantity_required;
$totalPrice = round($unitPrice * $qty, 3);
$isVatable = !empty($validated['items'][$i]['is_vatable']);
$subtotal += $totalPrice;
if ($isVatable && $vatRate > 0) {
$vatAmount += round($totalPrice * $vatRate / 100, 3);
}
SupplierQuoteItem::create([
'supplier_quote_id' => $quote->id,
'description' => $item->description,
'unit' => $item->unit ?? '',
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'is_vatable' => $isVatable,
]);
}
$grand = round($subtotal + $vatAmount, 3);
$quote->update(['total_amount' => $grand]);
```
- [ ] **Step 3: Commit**
```bash
git add app/Http/Controllers/Purchase/RfqPortalController.php
git commit -m "feat: load VAT rate and store is_vatable in RFQ portal"
```
---
### Task 5: Update rfq/show.blade.php — checkbox column + VAT footer + JS
**Files:**
- Modify: `resources/views/rfq/show.blade.php`
- [ ] **Step 1: Add VAT rate JS variable**
At the start of the `<script>` block, add:
```javascript
var _vatRate = {{ $vatRate }};
```
- [ ] **Step 2: Replace calcRow() and add recalcTotals()**
Replace the entire `calcRow` function and grand total logic with:
```javascript
var _totals = {};
var _vatable = {};
function calcRow(i, qty) {
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]');
var cb = document.querySelector('input[name="items[' + i + '][is_vatable]"]');
var up = parseFloat(inp ? inp.value : 0) || 0;
var tot = Math.round(up * qty * 1000) / 1000;
_totals[i] = tot;
_vatable[i] = cb ? cb.checked : false;
var el = document.getElementById('tot-' + i);
if (el) el.textContent = tot > 0 ? 'BD ' + tot.toFixed(3) : '—';
recalcTotals();
}
function recalcTotals() {
var subtotal = 0;
var vatAmount = 0;
Object.keys(_totals).forEach(function(i) {
subtotal += _totals[i];
if (_vatable[i] && _vatRate > 0) {
vatAmount += Math.round(_totals[i] * _vatRate / 100 * 1000) / 1000;
}
});
var grand = Math.round((subtotal + vatAmount) * 1000) / 1000;
var elSub = document.getElementById('subtotal-row');
var elVat = document.getElementById('vat-row');
var elGrand = document.getElementById('grand-total');
if (elSub) elSub.textContent = 'BD ' + subtotal.toFixed(3);
if (elVat) {
elVat.textContent = 'BD ' + vatAmount.toFixed(3);
var vatTr = document.getElementById('vat-tr');
if (vatTr) vatTr.style.display = _vatRate > 0 ? '' : 'none';
}
if (elGrand) elGrand.textContent = 'BD ' + grand.toFixed(3);
}
```
- [ ] **Step 3: Update table header**
Replace the `<thead>` block:
```html
<thead>
<tr>
<th>#</th>
<th>Description</th>
<th>Qty</th>
<th>Unit</th>
<th style="text-align:center;">VAT?</th>
<th style="text-align:right;">Unit Price (BD)</th>
<th style="text-align:right;">Total (BD)</th>
</tr>
</thead>
```
- [ ] **Step 4: Update each item row**
Replace the `@foreach` tbody rows:
```blade
@foreach($items as $i => $item)
<tr>
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td>
<td style="font-weight:500;">{{ $item->description }}</td>
<td>{{ rtrim(rtrim(number_format((float)$item->quantity_required, 3), '0'), '.') }}</td>
<td style="color:#64748b;">{{ $item->unit ?: '—' }}</td>
<td style="text-align:center;">
@if($vatRate > 0)
<input type="checkbox" name="items[{{ $i }}][is_vatable]" value="1"
onchange="calcRow({{ $i }}, {{ (float)$item->quantity_required }})"
style="width:16px;height:16px;accent-color:#2563eb;cursor:pointer;">
@else
<span style="color:#cbd5e1;font-size:11px;">—</span>
@endif
</td>
<td style="text-align:right;">
<input type="number" class="price" name="items[{{ $i }}][unit_price]"
min="0" step="0.001" required placeholder="0.000"
oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
</td>
<td style="text-align:right;font-weight:600;" id="tot-{{ $i }}">—</td>
</tr>
@endforeach
```
- [ ] **Step 5: Update tfoot**
Replace the `<tfoot>` block:
```html
<tfoot>
<tr style="background:#f8fafc;">
<td colspan="6" style="text-align:right;font-size:13px;color:#475569;">Subtotal:</td>
<td style="text-align:right;font-size:14px;color:#475569;" id="subtotal-row">BD 0.000</td>
</tr>
<tr id="vat-tr" style="background:#fffbeb;{{ $vatRate > 0 ? '' : 'display:none;' }}">
<td colspan="6" style="text-align:right;font-size:13px;color:#92400e;">VAT ({{ $vatRate }}%):</td>
<td style="text-align:right;font-size:14px;color:#92400e;" id="vat-row">BD 0.000</td>
</tr>
<tr style="background:#f8fafc;border-top:2px solid #e2e8f0;">
<td colspan="6" style="text-align:right;font-size:13px;color:#475569;font-weight:700;">Grand Total:</td>
<td style="text-align:right;font-size:15px;color:#2563eb;font-weight:700;" id="grand-total">BD 0.000</td>
</tr>
</tfoot>
```
- [ ] **Step 6: Commit**
```bash
git add resources/views/rfq/show.blade.php
git commit -m "feat: add VAT checkbox column and live breakdown to RFQ portal"
```
@@ -0,0 +1,133 @@
# VAT Settings + RFQ Per-Item Vatable Checkbox — Design Spec
**Date:** 2026-06-01
**Status:** Approved
---
## Overview
Two connected features:
1. **Global VAT setting** — Admin sets a VAT rate (%) once in Settings. Stored in the existing `settings` key/value table as `vat_rate`.
2. **Per-item vatable checkbox on RFQ portal** — When a supplier fills in their quote, each item row has a "VAT?" checkbox. Ticking it applies the global VAT rate to that item's total. The footer shows a live breakdown: Subtotal → VAT → Grand Total. Everything is stored against the quote on submission.
---
## 1. VAT Global Setting
### Route & Controller
- `GET settings/vat` → `VatSettingController@index` — renders the VAT settings page
- `POST settings/vat` → `VatSettingController@update` — saves the rate via AJAX, returns JSON
### Storage
- Uses the existing `Setting` model (`settings` table, key/value pairs)
- Key: `vat_rate`, value: decimal string e.g. `"10"` for 10%
- Default when not set: `0`
### View: `resources/views/settings/vat.blade.php`
- Extends `layouts.app`
- Single card with a number input: "VAT Rate (%)" — accepts decimals (e.g. 5, 10, 14.5)
- Save button submits via `fetch()` AJAX (no page reload, per project rules)
- Shows current saved value on load
- Success/error feedback via `showToast()`
### Sidebar
- Add "VAT Settings" link under the `@role('Admin')` System section in `layouts/app.blade.php`
- Active state: `request()->routeIs('settings.vat')`
---
## 2. RFQ Portal — Per-Item Vatable Checkbox
### Database
- New migration: add `is_vatable` boolean (default `false`) to `supplier_quote_items` table
- `SupplierQuoteItem::$fillable` gains `is_vatable`
### Controller: `RfqPortalController`
**`show()` method:**
- Load VAT rate: `$vatRate = (float) Setting::get('vat_rate', 0)`
- Pass `$vatRate` to `rfq.show` view
**`submit()` method:**
- Add validation rule: `'items.*.is_vatable' => ['nullable', 'boolean']`
- When creating each `SupplierQuoteItem`, set `is_vatable` from submitted checkbox value
- Calculate VAT: sum of `(unit_price × quantity)` for vatable items × `vatRate / 100`
- Store `total_amount` on `SupplierQuote` as the VAT-inclusive grand total
### View: `resources/views/rfq/show.blade.php`
**Table header** — add "VAT?" column:
```
# | Description | Qty | Unit | Unit Price (BD) | VAT? | Total (BD)
```
**Each item row** — add checkbox cell:
```html
<input type="checkbox" name="items[N][is_vatable]" value="1"
onchange="calcRow(N, qty)" style="accent-color:#2563eb;width:16px;height:16px;">
```
**Footer** — replace single "Grand Total" row with three rows:
```
Subtotal (before VAT): BD XXX.XXX
VAT (10%): BD XXX.XXX ← hidden when vatRate is 0
Grand Total: BD XXX.XXX
```
**JavaScript `calcRow()` update:**
- Each row calculates its own total (unit_price × qty), unchanged
- `recalcTotals()` called after every row change:
- Sums all row totals → subtotal
- Sums row totals for checked (vatable) rows × `vatRate/100` → vatAmount
- grand = subtotal + vatAmount
- Updates subtotal, VAT, and grand total display elements
- VAT rate injected as a JS variable: `var _vatRate = {{ $vatRate }};`
- VAT row is hidden when `_vatRate === 0`
### Mobile stacked layout
On mobile (≤640px), the table renders as cards. The VAT checkbox appears as a labelled row inside each card.
---
## 3. Data Flow Summary
```
Admin sets vat_rate = 10 in settings/vat
↓
RfqPortalController::show() reads vat_rate, passes to view
↓
Supplier ticks VAT on items 1 and 3
↓
JS: subtotal = sum(all items), vat = sum(vatable items) × 0.10, grand = subtotal + vat
↓
Supplier submits form
↓
RfqPortalController::submit():
- stores is_vatable per SupplierQuoteItem
- total_amount on SupplierQuote = subtotal + vat (VAT-inclusive)
```
---
## 4. Files Touched
| File | Change |
|------|--------|
| `routes/web.php` | Add `GET/POST settings/vat` routes |
| `app/Http/Controllers/Settings/VatSettingController.php` | New controller |
| `resources/views/settings/vat.blade.php` | New view |
| `resources/views/layouts/app.blade.php` | Add sidebar link |
| `database/migrations/..._add_is_vatable_to_supplier_quote_items.php` | New migration |
| `app/Models/SupplierQuoteItem.php` | Add `is_vatable` to `$fillable` |
| `app/Http/Controllers/Purchase/RfqPortalController.php` | Load VAT rate, store `is_vatable`, calc VAT total |
| `resources/views/rfq/show.blade.php` | Add checkbox column, VAT footer breakdown, JS update |
---
## 5. Out of Scope
- Showing VAT breakdown on the internal quote comparison view (can be added later)
- Per-supplier or per-item VAT rates (single global rate only)
- VAT registration numbers
+3
View File
@@ -0,0 +1,3 @@
/vendor/
composer.lock
*.cache
+82
View File
@@ -0,0 +1,82 @@
# promoseven/azure-mailer
Laravel mail transport for Microsoft 365 via the Azure AD Graph API.
Replaces SMTP with a Client Credentials OAuth2 flow — drop-in compatible with
Laravel's `Mail` facade, Mailables, Notifications, and queued mail.
## Requirements
- PHP 8.2+
- Laravel 11 or 12
- An Azure AD App Registration with `Mail.Send` application permission
## Installation
```bash
composer require promoseven/azure-mailer
```
## Azure AD Setup
1. Go to **Azure Portal → App Registrations → New registration**
2. Note the **Tenant ID**, **Client ID**
3. Under **Certificates & secrets**, create a new **Client secret**
4. Under **API permissions**, add **Microsoft Graph → Application permissions → Mail.Send**
5. Click **Grant admin consent**
## Configuration
Add to `config/mail.php` under `mailers`:
```php
'azure' => [
'transport' => 'azure',
'tenant_id' => env('AZURE_TENANT_ID'),
'client_id' => env('AZURE_CLIENT_ID'),
'client_secret' => env('AZURE_CLIENT_SECRET'),
'from_address' => env('AZURE_MAIL_FROM_ADDRESS'),
],
```
Set `.env`:
```env
MAIL_MAILER=azure
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
AZURE_MAIL_FROM_ADDRESS=noreply@yourdomain.com
```
The `from_address` must be a mailbox in your Microsoft 365 tenant.
## Advanced config (optional)
Publish the config file to override defaults:
```bash
php artisan vendor:publish --tag=azure-mailer-config
```
This creates `config/azure-mailer.php`:
```php
return [
'save_to_sent_items' => false, // set true to keep copies in Sent folder
'timeout' => 30, // HTTP timeout in seconds
'graph_api_version' => 'v1.0', // or 'beta'
];
```
## Usage
No changes needed — use Laravel mail exactly as before:
```php
Mail::to('user@example.com')->send(new OrderConfirmation($order));
```
## License
MIT
+40
View File
@@ -0,0 +1,40 @@
{
"name": "promoseven/azure-mailer",
"description": "Laravel mail transport for Microsoft 365 via Azure AD Graph API",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/cache": "^11.0|^12.0",
"illuminate/mail": "^11.0|^12.0",
"symfony/mailer": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"orchestra/testbench": "^10.0"
},
"autoload": {
"psr-4": {
"PromoSeven\\AzureMailer\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"PromoSeven\\AzureMailer\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"PromoSeven\\AzureMailer\\AzureMailerServiceProvider"
]
}
},
"scripts": {
"test": "vendor/bin/phpunit"
},
"minimum-stability": "stable",
"prefer-stable": true
}
@@ -0,0 +1,7 @@
<?php
return [
'save_to_sent_items' => false,
'timeout' => 30,
'graph_api_version' => 'v1.0',
];
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
use PromoSeven\AzureMailer\Graph\GraphClient;
use PromoSeven\AzureMailer\Graph\TokenManager;
use PromoSeven\AzureMailer\Transport\AzureTransport;
class AzureMailerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/azure-mailer.php', 'azure-mailer');
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../config/azure-mailer.php' => config_path('azure-mailer.php'),
], 'azure-mailer-config');
$this->callAfterResolving(MailManager::class, function (MailManager $manager) {
$manager->extend('azure', function (array $config) {
$merged = array_merge(config('azure-mailer', []), $config);
return new AzureTransport(
new GraphClient(new TokenManager($merged), $merged),
$merged
);
});
});
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Exceptions;
class AuthenticationException extends \RuntimeException
{
public static function fromResponse(string $error, string $description): self
{
return new self("Azure AD authentication failed: [{$error}] {$description}");
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Exceptions;
class GraphApiException extends \RuntimeException
{
public static function fromResponse(string $code, string $message): self
{
return new self("Graph API error: [{$code}] {$message}");
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Graph;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
class GraphClient
{
public function __construct(
private readonly TokenManager $tokenManager,
private readonly array $config
) {}
public function send(array $payload): void
{
$url = $this->endpoint();
$response = Http::withToken($this->tokenManager->getToken())
->timeout($this->config['timeout'] ?? 30)
->post($url, $payload);
if ($response->status() === 401) {
Log::warning('azure-mailer: 401 received, retrying with fresh token');
$this->tokenManager->invalidate();
$response = Http::withToken($this->tokenManager->getToken())
->timeout($this->config['timeout'] ?? 30)
->post($url, $payload);
}
if (! $response->successful()) {
$error = $response->json('error', []);
throw GraphApiException::fromResponse(
$error['code'] ?? (string) $response->status(),
$error['message'] ?? 'Unknown error'
);
}
}
private function endpoint(): string
{
$version = $this->config['graph_api_version'] ?? 'v1.0';
$from = $this->config['from_address'];
return "https://graph.microsoft.com/{$version}/users/{$from}/sendMail";
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Graph;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
class TokenManager
{
public function __construct(private readonly array $config) {}
public function getToken(): string
{
$key = $this->cacheKey();
if ($token = Cache::get($key)) {
return $token;
}
return $this->fetchAndCache($key);
}
public function invalidate(): void
{
Cache::forget($this->cacheKey());
}
private function fetchAndCache(string $key): string
{
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token",
[
'grant_type' => 'client_credentials',
'client_id' => $this->config['client_id'],
'client_secret' => $this->config['client_secret'],
'scope' => 'https://graph.microsoft.com/.default',
]
);
if (! $response->successful()) {
$body = $response->json();
throw AuthenticationException::fromResponse(
$body['error'] ?? 'unknown_error',
$body['error_description'] ?? 'No description provided'
);
}
$body = $response->json();
$ttl = max(1, ($body['expires_in'] ?? 3600) - 60);
Cache::put($key, $body['access_token'], $ttl);
return $body['access_token'];
}
private function cacheKey(): string
{
return 'azure_mailer_token_' . $this->config['client_id'];
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Transport;
use PromoSeven\AzureMailer\Graph\GraphClient;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Part\DataPart;
class AzureTransport extends AbstractTransport
{
public function __construct(
private readonly GraphClient $client,
private readonly array $config = []
) {
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$email = $message->getOriginalMessage();
if (! $email instanceof Email) {
return;
}
$this->client->send($this->buildPayload($email));
}
public function __toString(): string
{
return 'azure';
}
private function buildPayload(Email $email): array
{
return [
'message' => [
'subject' => $email->getSubject() ?? '',
'body' => [
'contentType' => $email->getHtmlBody() !== null ? 'HTML' : 'Text',
'content' => $email->getHtmlBody() ?? $email->getTextBody() ?? '',
],
'toRecipients' => $this->mapAddresses($email->getTo()),
'ccRecipients' => $this->mapAddresses($email->getCc()),
'bccRecipients' => $this->mapAddresses($email->getBcc()),
'replyTo' => $this->mapAddresses($email->getReplyTo()),
'attachments' => $this->mapAttachments($email),
],
'saveToSentItems' => (bool) ($this->config['save_to_sent_items'] ?? false),
];
}
private function mapAddresses(array $addresses): array
{
return array_map(fn ($addr) => [
'emailAddress' => [
'address' => $addr->getAddress(),
'name' => $addr->getName() ?? '',
],
], $addresses);
}
private function mapAttachments(Email $email): array
{
$result = [];
foreach ($email->getAttachments() as $attachment) {
if (! $attachment instanceof DataPart) {
continue;
}
$result[] = [
'@odata.type' => '#microsoft.graph.fileAttachment',
'name' => $attachment->getFilename() ?? 'attachment',
'contentType' => $attachment->getMediaType() . '/' . $attachment->getMediaSubtype(),
'contentBytes' => base64_encode($attachment->getBody()),
];
}
return $result;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests;
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
class ExceptionsTest extends \PHPUnit\Framework\TestCase
{
public function test_authentication_exception_formats_message(): void
{
$e = AuthenticationException::fromResponse('invalid_client', 'The client secret is incorrect.');
$this->assertInstanceOf(\RuntimeException::class, $e);
$this->assertStringContainsString('invalid_client', $e->getMessage());
$this->assertStringContainsString('The client secret is incorrect.', $e->getMessage());
}
public function test_graph_api_exception_formats_message(): void
{
$e = GraphApiException::fromResponse('ErrorItemNotFound', 'The specified object was not found.');
$this->assertInstanceOf(\RuntimeException::class, $e);
$this->assertStringContainsString('ErrorItemNotFound', $e->getMessage());
$this->assertStringContainsString('The specified object was not found.', $e->getMessage());
}
}
Whitespace-only changes.
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests\Graph;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
use PromoSeven\AzureMailer\Graph\GraphClient;
use PromoSeven\AzureMailer\Graph\TokenManager;
use PromoSeven\AzureMailer\Tests\TestCase;
class GraphClientTest extends TestCase
{
private array $config = [
'tenant_id' => 'test-tenant',
'client_id' => 'test-client-id',
'client_secret' => 'test-secret',
'from_address' => '[email protected]',
'timeout' => 30,
'graph_api_version' => 'v1.0',
];
private array $payload = [
'message' => [
'subject' => 'Test',
'body' => ['contentType' => 'HTML', 'content' => '<p>Hello</p>'],
'toRecipients' => [['emailAddress' => ['address' => '[email protected]', 'name' => '']]],
'ccRecipients' => [],
'bccRecipients' => [],
'replyTo' => [],
'attachments' => [],
],
'saveToSentItems' => false,
];
public function test_sends_payload_with_bearer_token(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response('', 202),
]);
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.microsoft.com/v1.0/users/[email protected]/sendMail')
&& $request->hasHeader('Authorization', 'Bearer tok');
});
}
public function test_retries_with_fresh_token_on_401(): void
{
Log::shouldReceive('warning')
->once()
->with('azure-mailer: 401 received, retrying with fresh token');
Http::fake([
'login.microsoftonline.com/*' => Http::sequence()
->push(['access_token' => 'stale-token', 'expires_in' => 3600], 200)
->push(['access_token' => 'fresh-token', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::sequence()
->push('', 401)
->push('', 202),
]);
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
Http::assertSentCount(4); // 2 token fetches + 2 graph calls
}
public function test_throws_graph_api_exception_on_second_401(): void
{
Log::shouldReceive('warning')->once();
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response([
'error' => ['code' => 'InvalidAuthenticationToken', 'message' => 'Token is expired.'],
], 401),
]);
$this->expectException(GraphApiException::class);
$this->expectExceptionMessage('InvalidAuthenticationToken');
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
}
public function test_throws_graph_api_exception_on_other_error(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response([
'error' => ['code' => 'ErrorInvalidRecipients', 'message' => 'Recipient address is invalid.'],
], 400),
]);
$this->expectException(GraphApiException::class);
$this->expectExceptionMessage('ErrorInvalidRecipients');
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
}
public function test_uses_graph_api_version_from_config(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response('', 202),
]);
$config = array_merge($this->config, ['graph_api_version' => 'beta']);
$client = new GraphClient(new TokenManager($config), $config);
$client->send($this->payload);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.microsoft.com/beta/');
});
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests\Graph;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
use PromoSeven\AzureMailer\Graph\TokenManager;
use PromoSeven\AzureMailer\Tests\TestCase;
class TokenManagerTest extends TestCase
{
private array $config = [
'tenant_id' => 'test-tenant',
'client_id' => 'test-client-id',
'client_secret' => 'test-secret',
];
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_fetches_token_from_azure_on_cache_miss(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'my-access-token',
'expires_in' => 3600,
'token_type' => 'Bearer',
], 200),
]);
$manager = new TokenManager($this->config);
$token = $manager->getToken();
$this->assertSame('my-access-token', $token);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'test-tenant/oauth2/v2.0/token')
&& $request['grant_type'] === 'client_credentials'
&& $request['client_id'] === 'test-client-id'
&& $request['client_secret'] === 'test-secret'
&& $request['scope'] === 'https://graph.microsoft.com/.default';
});
}
public function test_returns_cached_token_without_hitting_azure(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'first-token',
'expires_in' => 3600,
], 200),
]);
$manager = new TokenManager($this->config);
$manager->getToken(); // first call — hits Azure
$manager->getToken(); // second call — should use cache
Http::assertSentCount(1);
}
public function test_invalidate_clears_cached_token(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::sequence()
->push(['access_token' => 'token-one', 'expires_in' => 3600], 200)
->push(['access_token' => 'token-two', 'expires_in' => 3600], 200),
]);
$manager = new TokenManager($this->config);
$manager->getToken(); // fetches token-one
$manager->invalidate(); // clears cache
$second = $manager->getToken(); // fetches token-two
$this->assertSame('token-two', $second);
Http::assertSentCount(2);
}
public function test_token_is_cached_with_ttl_of_expires_in_minus_60(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'ttl-token',
'expires_in' => 120, // 120 - 60 = 60 second TTL
], 200),
]);
$manager = new TokenManager($this->config);
$manager->getToken();
// Token should be in cache immediately after fetch
$this->assertTrue(Cache::has('azure_mailer_token_test-client-id'));
// Advance time past the TTL (61 seconds)
$this->travel(61)->seconds();
// Token should now be expired from cache
$this->assertFalse(Cache::has('azure_mailer_token_test-client-id'));
}
public function test_throws_authentication_exception_on_azure_error(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'error' => 'invalid_client',
'error_description' => 'The client secret supplied is incorrect.',
], 401),
]);
$this->expectException(AuthenticationException::class);
$this->expectExceptionMessage('invalid_client');
(new TokenManager($this->config))->getToken();
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests;
use Illuminate\Mail\MailManager;
use PromoSeven\AzureMailer\Transport\AzureTransport;
class ServiceProviderTest extends TestCase
{
protected function defineEnvironment($app): void
{
parent::defineEnvironment($app);
$app['config']->set('mail.mailers.azure', [
'transport' => 'azure',
'tenant_id' => 'test-tenant',
'client_id' => 'test-client-id',
'client_secret' => 'test-secret',
'from_address' => '[email protected]',
]);
}
public function test_azure_transport_is_registered_with_mail_manager(): void
{
$manager = $this->app->make(MailManager::class);
$transport = $manager->mailer('azure')->getSymfonyTransport();
$this->assertInstanceOf(AzureTransport::class, $transport);
$this->assertSame('azure', (string) $transport);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
use PromoSeven\AzureMailer\AzureMailerServiceProvider;
abstract class TestCase extends OrchestraTestCase
{
protected function getPackageProviders($app): array
{
return [AzureMailerServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('cache.default', 'array');
}
}
Whitespace-only changes.
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests\Transport;
use PromoSeven\AzureMailer\Graph\GraphClient;
use PromoSeven\AzureMailer\Tests\TestCase;
use PromoSeven\AzureMailer\Transport\AzureTransport;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mime\Email;
class AzureTransportTest extends TestCase
{
public function test_sends_html_body(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->expects($this->once())
->method('send')
->willReturnCallback(function (array $payload) use (&$captured) {
$captured = $payload;
});
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Hello')
->html('<p>World</p>');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$this->assertSame('Hello', $captured['message']['subject']);
$this->assertSame('HTML', $captured['message']['body']['contentType']);
$this->assertSame('<p>World</p>', $captured['message']['body']['content']);
$this->assertSame('[email protected]', $captured['message']['toRecipients'][0]['emailAddress']['address']);
$this->assertFalse($captured['saveToSentItems']);
}
public function test_falls_back_to_text_body_when_no_html(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Text only')
->text('Plain text content');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$this->assertSame('Text', $captured['message']['body']['contentType']);
$this->assertSame('Plain text content', $captured['message']['body']['content']);
}
public function test_maps_cc_bcc_and_reply_to(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->cc('[email protected]')
->bcc('[email protected]')
->replyTo('[email protected]')
->subject('Recipients test')
->html('<p>hi</p>');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$this->assertSame('[email protected]', $captured['message']['ccRecipients'][0]['emailAddress']['address']);
$this->assertSame('[email protected]', $captured['message']['bccRecipients'][0]['emailAddress']['address']);
$this->assertSame('[email protected]', $captured['message']['replyTo'][0]['emailAddress']['address']);
}
public function test_encodes_attachments_as_base64(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('With attachment')
->html('<p>see attachment</p>')
->attach('PDF content here', 'report.pdf', 'application/pdf');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$attachment = $captured['message']['attachments'][0];
$this->assertSame('#microsoft.graph.fileAttachment', $attachment['@odata.type']);
$this->assertSame('report.pdf', $attachment['name']);
$this->assertSame(base64_encode('PDF content here'), $attachment['contentBytes']);
$this->assertSame('application/pdf', $attachment['contentType']);
}
public function test_save_to_sent_items_is_configurable(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Save it')
->html('<p>keep</p>');
$transport = new AzureTransport($client, ['save_to_sent_items' => true]);
$transport->send($email, Envelope::create($email));
$this->assertTrue($captured['saveToSentItems']);
}
public function test_to_string_returns_azure(): void
{
$client = $this->createMock(GraphClient::class);
$transport = new AzureTransport($client, []);
$this->assertSame('azure', (string) $transport);
}
}
+2
View File
@@ -0,0 +1,2 @@
/vendor/
composer.lock
+42
View File
@@ -0,0 +1,42 @@
{
"name": "promoseven/ultra-message",
"description": "Laravel WhatsApp integration via UltraMSG API",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/notifications": "^11.0|^12.0",
"illuminate/routing": "^11.0|^12.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"orchestra/testbench": "^10.0"
},
"autoload": {
"psr-4": {
"PromoSeven\\UltraMessage\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"PromoSeven\\UltraMessage\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"PromoSeven\\UltraMessage\\UltraMessageServiceProvider"
],
"aliases": {
"UltraMessage": "PromoSeven\\UltraMessage\\Facades\\UltraMessage"
}
}
},
"scripts": {
"test": "vendor/bin/phpunit"
},
"minimum-stability": "stable",
"prefer-stable": true
}
@@ -0,0 +1,10 @@
<?php
return [
'instance_id' => env('ULTRAMSG_INSTANCE_ID'),
'token' => env('ULTRAMSG_TOKEN'),
'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null),
'webhook_path' => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'),
'timeout' => env('ULTRAMSG_TIMEOUT', 30),
'enabled' => env('ULTRAMSG_ENABLED', true),
];
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use PromoSeven\UltraMessage\Http\Controllers\WebhookController;
Route::post(config('ultra-message.webhook_path', 'ultra-message/webhook'), [WebhookController::class, 'handle'])
->name('ultra-message.webhook');
@@ -0,0 +1,13 @@
<?php
namespace PromoSeven\UltraMessage\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UltraMessageWebhookReceived
{
use Dispatchable, SerializesModels;
public function __construct(public readonly array $payload) {}
}
@@ -0,0 +1,47 @@
<?php
namespace PromoSeven\UltraMessage\Facades;
use Illuminate\Support\Facades\Facade;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageFake;
/**
* @method static array sendText(string $to, string $message, ?string $replyId = null)
* @method static array sendImage(string $to, string $imageUrl, string $caption = '')
* @method static array sendDocument(string $to, string $fileUrl, string $filename, string $caption = '')
* @method static array sendAudio(string $to, string $audioUrl)
* @method static array sendVoice(string $to, string $audioUrl)
* @method static array sendVideo(string $to, string $videoUrl, string $caption = '')
* @method static array sendSticker(string $to, string $stickerUrl)
* @method static array sendContact(string $to, string $contactId)
* @method static array sendLocation(string $to, float $lat, float $lng, string $address = '')
* @method static array sendReaction(string $to, string $messageId, string $emoji)
* @method static array deleteMessage(string $messageId)
* @method static array getInstanceStatus()
* @method static array getChats()
* @method static array getContacts()
* @method static array getGroups()
*
* @see \PromoSeven\UltraMessage\UltraMessageClient
*/
class UltraMessage extends Facade
{
protected static function getFacadeAccessor(): string
{
return UltraMessageClient::class;
}
public static function fake(): UltraMessageFake
{
$fake = new UltraMessageFake();
static::swap($fake);
return $fake;
}
public static function configUsing(callable $resolver): void
{
app()->instance('ultra-message.config-resolver', $resolver);
app()->forgetInstance(UltraMessageClient::class);
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,29 @@
<?php
namespace PromoSeven\UltraMessage\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use PromoSeven\UltraMessage\Events\UltraMessageWebhookReceived;
class WebhookController extends Controller
{
public function handle(Request $request): Response
{
$secret = config('ultra-message.webhook_secret');
if ($secret) {
$signature = $request->header('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);
if (!hash_equals($expected, $signature)) {
abort(403, 'Invalid webhook signature.');
}
}
event(new UltraMessageWebhookReceived($request->all()));
return response('OK', 200);
}
}
@@ -0,0 +1,39 @@
<?php
namespace PromoSeven\UltraMessage;
use Illuminate\Notifications\Notification;
class UltraMessageChannel
{
public function __construct(private UltraMessageClient $client) {}
public function send(mixed $notifiable, Notification $notification): void
{
if (!method_exists($notification, 'toUltraMessage')) {
return;
}
/** @var UltraMessageMessage $message */
$message = $notification->toUltraMessage($notifiable);
$to = $message->to ?: $notifiable->routeNotificationFor('ultra_message', $notification);
if (!$to) {
return;
}
match ($message->type) {
'text' => $this->client->sendText($to, $message->payload['body'], $message->payload['quoted_id'] ?? null),
'image' => $this->client->sendImage($to, $message->payload['image'], $message->payload['caption'] ?? ''),
'document' => $this->client->sendDocument($to, $message->payload['document'], $message->payload['filename'], $message->payload['caption'] ?? ''),
'audio' => $this->client->sendAudio($to, $message->payload['audio']),
'voice' => $this->client->sendVoice($to, $message->payload['audio']),
'video' => $this->client->sendVideo($to, $message->payload['video'], $message->payload['caption'] ?? ''),
'sticker' => $this->client->sendSticker($to, $message->payload['sticker']),
'contact' => $this->client->sendContact($to, $message->payload['contact']),
'location' => $this->client->sendLocation($to, $message->payload['lat'], $message->payload['lng'], $message->payload['address'] ?? ''),
default => throw new UltraMessageException("Unknown message type: {$message->type}"),
};
}
}
@@ -0,0 +1,187 @@
<?php
namespace PromoSeven\UltraMessage;
use Illuminate\Support\Facades\Http;
class UltraMessageClient
{
private const BASE_URL = 'https://api.ultramsg.com';
private string $instanceId;
private string $token;
private int $timeout;
private bool $enabled;
public function __construct(array $config)
{
$this->instanceId = $config['instance_id'] ?? '';
$this->token = $config['token'] ?? '';
$this->timeout = $config['timeout'] ?? 30;
$this->enabled = $config['enabled'] ?? true;
}
protected function post(string $endpoint, array $data): array
{
if (!$this->enabled) {
return [];
}
$response = Http::timeout($this->timeout)
->asForm()
->post(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($data, [
'token' => $this->token,
]));
if ($response->failed()) {
throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}");
}
$body = $response->json() ?? [];
if (isset($body['error'])) {
throw new UltraMessageException($body['error']);
}
return $body;
}
protected function get(string $endpoint, array $query = []): array
{
if (!$this->enabled) {
return [];
}
$response = Http::timeout($this->timeout)
->get(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($query, [
'token' => $this->token,
]));
if ($response->failed()) {
throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}");
}
$body = $response->json() ?? [];
if (isset($body['error'])) {
throw new UltraMessageException($body['error']);
}
return $body;
}
public function sendText(string $to, string $message, ?string $replyId = null): array
{
$data = ['to' => $to, 'body' => $message];
if ($replyId !== null) {
$data['quoted_id'] = $replyId;
}
return $this->post('messages/chat', $data);
}
public function sendImage(string $to, string $imageUrl, string $caption = ''): array
{
return $this->post('messages/image', [
'to' => $to,
'image' => $imageUrl,
'caption' => $caption,
]);
}
public function sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array
{
return $this->post('messages/document', [
'to' => $to,
'document' => $fileUrl,
'filename' => $filename,
'caption' => $caption,
]);
}
public function sendAudio(string $to, string $audioUrl): array
{
return $this->post('messages/audio', [
'to' => $to,
'audio' => $audioUrl,
]);
}
public function sendVoice(string $to, string $audioUrl): array
{
return $this->post('messages/voice', [
'to' => $to,
'audio' => $audioUrl,
]);
}
public function sendVideo(string $to, string $videoUrl, string $caption = ''): array
{
return $this->post('messages/video', [
'to' => $to,
'video' => $videoUrl,
'caption' => $caption,
]);
}
public function sendSticker(string $to, string $stickerUrl): array
{
return $this->post('messages/sticker', [
'to' => $to,
'sticker' => $stickerUrl,
]);
}
public function sendContact(string $to, string $contactId): array
{
return $this->post('messages/contact', [
'to' => $to,
'contact' => $contactId,
]);
}
public function sendLocation(string $to, float $lat, float $lng, string $address = ''): array
{
return $this->post('messages/location', [
'to' => $to,
'lat' => $lat,
'lng' => $lng,
'address' => $address,
]);
}
public function sendReaction(string $to, string $messageId, string $emoji): array
{
return $this->post('messages/reaction', [
'to' => $to,
'msgId' => $messageId,
'emoji' => $emoji,
]);
}
public function deleteMessage(string $messageId): array
{
return $this->post('messages/delete', [
'msgId' => $messageId,
]);
}
public function getInstanceStatus(): array
{
return $this->get('instance/status');
}
public function getChats(): array
{
return $this->get('chats/');
}
public function getContacts(): array
{
return $this->get('contacts/');
}
public function getGroups(): array
{
return $this->get('groups/');
}
}
@@ -0,0 +1,7 @@
<?php
namespace PromoSeven\UltraMessage;
use RuntimeException;
class UltraMessageException extends RuntimeException {}
@@ -0,0 +1,49 @@
<?php
namespace PromoSeven\UltraMessage;
use PHPUnit\Framework\Assert;
class UltraMessageFake extends UltraMessageClient
{
private array $sent = [];
public function __construct()
{
// Skip parent constructor — no HTTP config needed in fake mode
}
protected function post(string $endpoint, array $data): array
{
$this->sent[] = ['endpoint' => $endpoint, 'data' => $data];
return ['sent' => 'ok'];
}
protected function get(string $endpoint, array $query = []): array
{
return [];
}
public function assertSent(callable $callback): void
{
Assert::assertTrue(
collect($this->sent)->contains($callback),
'Expected UltraMessage was not sent.'
);
}
public function assertNotSent(): void
{
Assert::assertEmpty($this->sent, 'Unexpected UltraMessage messages were sent.');
}
public function assertSentCount(int $count): void
{
Assert::assertCount($count, $this->sent, "Expected {$count} messages sent, got " . count($this->sent));
}
public function getSent(): array
{
return $this->sent;
}
}
@@ -0,0 +1,67 @@
<?php
namespace PromoSeven\UltraMessage;
class UltraMessageMessage
{
public string $type;
public string $to = '';
public array $payload = [];
private function __construct(string $type, array $payload)
{
$this->type = $type;
$this->payload = $payload;
}
public static function text(string $message, ?string $replyId = null): self
{
return new self('text', ['body' => $message, 'quoted_id' => $replyId]);
}
public static function image(string $url, string $caption = ''): self
{
return new self('image', ['image' => $url, 'caption' => $caption]);
}
public static function document(string $url, string $filename, string $caption = ''): self
{
return new self('document', ['document' => $url, 'filename' => $filename, 'caption' => $caption]);
}
public static function audio(string $url): self
{
return new self('audio', ['audio' => $url]);
}
public static function voice(string $url): self
{
return new self('voice', ['audio' => $url]);
}
public static function video(string $url, string $caption = ''): self
{
return new self('video', ['video' => $url, 'caption' => $caption]);
}
public static function sticker(string $url): self
{
return new self('sticker', ['sticker' => $url]);
}
public static function contact(string $contactId): self
{
return new self('contact', ['contact' => $contactId]);
}
public static function location(float $lat, float $lng, string $address = ''): self
{
return new self('location', ['lat' => $lat, 'lng' => $lng, 'address' => $address]);
}
public function to(string $number): self
{
$this->to = $number;
return $this;
}
}
@@ -0,0 +1,37 @@
<?php
namespace PromoSeven\UltraMessage;
use Illuminate\Support\ServiceProvider;
class UltraMessageServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/ultra-message.php', 'ultra-message');
$this->app->singleton('ultra-message.config-resolver', fn() => null);
$this->app->bind(UltraMessageClient::class, function ($app) {
$resolver = $app->make('ultra-message.config-resolver');
$config = $resolver ? call_user_func($resolver) : config('ultra-message');
return new UltraMessageClient($config);
});
$this->app->bind(UltraMessageChannel::class, function ($app) {
return new UltraMessageChannel($app->make(UltraMessageClient::class));
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/ultra-message.php' => config_path('ultra-message.php'),
], 'ultra-message-config');
}
$this->loadRoutesFrom(__DIR__ . '/../routes/webhook.php');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace PromoSeven\UltraMessage\Tests;
use Orchestra\Testbench\TestCase as BaseTestCase;
use PromoSeven\UltraMessage\UltraMessageServiceProvider;
abstract class TestCase extends BaseTestCase
{
protected function getPackageProviders($app): array
{
return [UltraMessageServiceProvider::class];
}
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('ultra-message.instance_id', 'instance123');
$app['config']->set('ultra-message.token', 'test-token');
$app['config']->set('ultra-message.enabled', true);
}
}
@@ -0,0 +1,110 @@
<?php
namespace PromoSeven\UltraMessage\Tests;
use Illuminate\Support\Facades\Http;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageMessage;
class UltraMessageChannelTest extends TestCase
{
public function test_channel_calls_send_text_for_text_message(): void
{
Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]);
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
$channel = new UltraMessageChannel($client);
$notifiable = new class {
public string $whatsapp_number = '+971501234567';
public function routeNotificationFor(string $channel, $notification = null): string
{
return $this->whatsapp_number;
}
};
$notification = new class extends Notification {
public function toUltraMessage($notifiable): UltraMessageMessage
{
return UltraMessageMessage::text('Test message');
}
};
$channel->send($notifiable, $notification);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'messages/chat')
&& $request['body'] === 'Test message'
&& $request['to'] === '+971501234567';
});
}
public function test_channel_uses_message_to_over_notifiable_route(): void
{
Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]);
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
$channel = new UltraMessageChannel($client);
$notifiable = new class {
public function routeNotificationFor(string $channel, $notification = null): string
{
return '+9710000000';
}
};
$notification = new class extends Notification {
public function toUltraMessage($notifiable): UltraMessageMessage
{
return UltraMessageMessage::text('Override test')->to('+971999999');
}
};
$channel->send($notifiable, $notification);
Http::assertSent(fn($r) => $r['to'] === '+971999999');
}
public function test_channel_skips_when_no_recipient(): void
{
Http::fake();
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
$channel = new UltraMessageChannel($client);
$notifiable = new class {
public function routeNotificationFor(string $channel, $notification = null): ?string
{
return null;
}
};
$notification = new class extends Notification {
public function toUltraMessage($notifiable): UltraMessageMessage
{
return UltraMessageMessage::text('No recipient');
}
};
$channel->send($notifiable, $notification);
Http::assertNothingSent();
}
}
@@ -0,0 +1,128 @@
<?php
namespace PromoSeven\UltraMessage\Tests;
use Illuminate\Support\Facades\Http;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageException;
class UltraMessageClientTest extends TestCase
{
private UltraMessageClient $client;
protected function setUp(): void
{
parent::setUp();
$this->client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
}
public function test_send_text_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true', 'id' => 'msg1'], 200),
]);
$result = $this->client->sendText('+971501234567', 'Hello World');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/chat')
&& $request['to'] === '+971501234567'
&& $request['body'] === 'Hello World'
&& $request['token'] === 'test-token';
});
$this->assertEquals(['sent' => 'true', 'id' => 'msg1'], $result);
}
public function test_send_text_throws_on_api_error(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['error' => 'invalid token'], 200),
]);
$this->expectException(UltraMessageException::class);
$this->expectExceptionMessage('invalid token');
$this->client->sendText('+971501234567', 'Hello');
}
public function test_send_text_throws_on_http_failure(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response([], 500),
]);
$this->expectException(UltraMessageException::class);
$this->client->sendText('+971501234567', 'Hello');
}
public function test_send_returns_early_when_disabled(): void
{
Http::fake();
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => false,
]);
$result = $client->sendText('+971501234567', 'Hello');
Http::assertNothingSent();
$this->assertEquals([], $result);
}
public function test_send_image_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
]);
$this->client->sendImage('+971501234567', 'https://example.com/img.jpg', 'Caption');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/image')
&& $request['image'] === 'https://example.com/img.jpg'
&& $request['caption'] === 'Caption';
});
}
public function test_send_document_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
]);
$this->client->sendDocument('+971501234567', 'https://example.com/file.pdf', 'invoice.pdf', 'Your invoice');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/document')
&& $request['document'] === 'https://example.com/file.pdf'
&& $request['filename'] === 'invoice.pdf'
&& $request['caption'] === 'Your invoice';
});
}
public function test_send_location_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
]);
$this->client->sendLocation('+971501234567', 25.197197, 55.2721877, 'Dubai, UAE');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/location')
&& $request['lat'] == 25.197197
&& $request['lng'] == 55.2721877
&& $request['address'] === 'Dubai, UAE';
});
}
}
+5
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

@@ -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;
@@ -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,
'locations' => $p->locations->map(function ($l) { 'company_id' => $p->company_id,
'company_name' => $p->company ? $p->company->name : '',
'label' => ($p->company ? $p->company->name . ' — ' : '') . $p->name,
'locations' => $p->locations->map(function ($l) {
return ['name' => $l->name]; 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') }}">
@foreach($mprProjects as $proj) {{-- Trigger --}}
<option value="{{ $proj->name }}" {{ old('project_name') == $proj->name ? 'selected' : '' }} data-id="{{ $proj->id }}">{{ $proj->name }}</option> <button type="button" id="mpr-project-trigger"
@endforeach onclick="mprProjectToggle()"
</select> 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)
<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
<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 tr = document.createElement('tr'); var date = mprLastRowDate();
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 mprOldLocation = "{{ old('location') }}"; var mprDepartmentsData = @json($mprDepartmentsJson);
var mprOldProjectName = "{{ old('project_name') }}"; var mprOldLocation = "{{ old('location') }}";
var mprOldProjectName = "{{ old('project_name') }}";
var mprOldDepartment = "{{ old('department') }}";
// ── Searchable project dropdown ───────────────────────────────────────────────
function mprProjectToggle() {
var panel = document.getElementById('mpr-project-panel');
var open = panel.style.display !== 'none';
if (open) {
mprProjectClose();
} else {
panel.style.display = 'block';
var search = document.getElementById('mpr-project-search');
search.value = '';
mprProjectSearch('');
setTimeout(function(){ search.focus(); }, 30);
}
}
function mprProjectClose() {
document.getElementById('mpr-project-panel').style.display = 'none';
}
function mprProjectSearch(q) {
var items = document.querySelectorAll('#mpr-project-list .mpr-proj-opt');
var empty = document.getElementById('mpr-proj-empty');
var term = q.trim().toLowerCase();
var visible = 0;
items.forEach(function(li) {
var match = !term || li.dataset.search.indexOf(term) !== -1;
li.style.display = match ? '' : 'none';
if (match) visible++;
});
empty.style.display = visible === 0 ? '' : 'none';
}
function mprProjectSelect(value, label) {
document.getElementById('mpr-project-value').value = value;
var lbl = document.getElementById('mpr-project-label');
lbl.textContent = label;
lbl.style.color = '#111827';
mprProjectClose();
mprOnProjectChange(value);
}
// Close panel when clicking outside
document.addEventListener('click', function(e) {
var wrapper = document.getElementById('mpr-project-wrapper');
if (wrapper && !wrapper.contains(e.target)) mprProjectClose();
});
// ── Location cascade ──────────────────────────────────────────────────────────
function mprFilterLocations(projectName) { 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>
@@ -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>'; html += '<div style="padding:10px 14px;display:flex;align-items:center;gap:8px;">';
html += '<a href="' + escHtml(inv.url) + '" target="_blank"'
checked.forEach(function(cb) { + ' style="flex:1;display:flex;align-items:center;gap:8px;padding:9px 14px;'
var supName = cb.dataset.supname || cb.value; + 'background:#eff6ff;border:1.5px solid #bfdbfe;border-radius:8px;text-decoration:none;'
var chan = (document.getElementById('ichan-val-' + cb.value) || {}).value || 'email'; + 'color:#1d4ed8;font-size:12px;font-weight:600;transition:background .15s;overflow:hidden;"'
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>'; + ' onmouseover="this.style.background=\'#dbeafe\'" onmouseout="this.style.background=\'#eff6ff\'">'
html += '<div style="padding:9px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #f8fafc;">'; + '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;">'
html += '<span style="font-size:13px;font-weight:600;color:#0f172a;">' + escHtml(supName) + '</span>'; + '<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"/>'
html += cl; + '</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>'; 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>
+117 -1
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'])
@@ -197,6 +199,17 @@
</svg> </svg>
Integrations Integrations
</a> </a>
<a href="{{ route('settings.vat') }}" style="
display:flex; align-items:center; gap:8px;
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
font-size:13px; text-decoration:none;
{{ request()->routeIs('settings.vat*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/>
</svg>
VAT Settings
</a>
@endrole @endrole
<div style="height:16px;"></div> <div style="height:16px;"></div>
@@ -249,6 +262,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 +538,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.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: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');
+3 -1
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">
+222 -10
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;
<div style="font-weight:600;color:#0f172a;">{{ $quote->supplier->name }}</div> background:{{ $quote->is_awarded ? '#f0fdf4' : ($isLowest ? '#eff6ff' : '#f8fafc') }};
<div style="color:{{ $quote->is_awarded ? '#15803d' : '#374151' }};font-weight:700;"> border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }};"
{{ number_format($quote->total_amount, 2) }} 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>
@if($isLowest)
<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 ----
@@ -48,7 +48,7 @@
{{-- Per-item rows --}} {{-- Per-item rows --}}
@foreach($items as $i => $reqItem) @foreach($items as $i => $reqItem)
@php @php
$rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price)->filter()->values(); $rowPrices = $quotes->map(fn($q) => ($q->items->get($i) && !$q->items->get($i)->not_available) ? $q->items->get($i)->unit_price : null)->filter()->values();
$minPrice = $rowPrices->count() ? $rowPrices->min() : null; $minPrice = $rowPrices->count() ? $rowPrices->min() : null;
@endphp @endphp
<tr> <tr>
@@ -57,14 +57,24 @@
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Qty: {{ $reqItem->quantity }} {{ $reqItem->unit }}</div> <div style="font-size:11px;color:#94a3b8;margin-top:2px;">Qty: {{ $reqItem->quantity }} {{ $reqItem->unit }}</div>
</td> </td>
@foreach($quotes as $q) @foreach($quotes as $q)
@php $qItem = $q->items->get($i); $isMin = $qItem && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp @php $qItem = $q->items->get($i); $isMin = $qItem && !$qItem->not_available && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp
<td style="padding:10px 14px;border-bottom:1px solid #f1f5f9;text-align:center;background:{{ $isMin ? '#f0fdf4' : '' }};"> <td style="padding:10px 14px;border-bottom:1px solid #f1f5f9;text-align:center;background:{{ $isMin ? '#f0fdf4' : '' }};">
@if($qItem) @if($qItem)
<div style="font-weight:600;color:{{ $isMin ? '#15803d' : '#0f172a' }};"> @if($qItem->not_available)
@if($isMin)<span style="font-size:10px;font-weight:700;color:#15803d;display:block;">LOWEST</span>@endif <span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 8px;border-radius:4px;border:1px solid #fecaca;">Not available</span>
BD {{ number_format($qItem->unit_price, 3) }} @else
</div> @if($qItem->supplier_description)
<div style="font-size:11px;color:#64748b;margin-top:2px;">BD {{ number_format($qItem->total_price, 3) }}</div> <div style="font-size:10px;color:#64748b;margin-bottom:2px;font-style:italic;">
"{{ $qItem->supplier_description }}"
<span style="background:#fef3c7;color:#92400e;font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;border:1px solid #fde68a;font-style:normal;margin-left:2px;">adjusted</span>
</div>
@endif
<div style="font-weight:600;color:{{ $isMin ? '#15803d' : '#0f172a' }};">
@if($isMin)<span style="font-size:10px;font-weight:700;color:#15803d;display:block;">LOWEST</span>@endif
BD {{ number_format($qItem->unit_price, 3) }}
</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">BD {{ number_format($qItem->total_price, 3) }}</div>
@endif
@else @else
<span style="color:#e2e8f0;">—</span> <span style="color:#e2e8f0;">—</span>
@endif @endif
+275 -34
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:11px 16px;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:12px 16px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
tfoot td{padding:12px;font-weight:700;} tfoot td{padding:13px 16px;font-weight:700;}
input[type=number].price{width:130px;text-align:right;} input[type=number].price{width:140px;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:980px;margin:0 auto;overflow:hidden;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 36px;}
.body{padding:36px;}
}
/* ── 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>
@@ -59,21 +78,64 @@
<th>Description</th> <th>Description</th>
<th>Qty</th> <th>Qty</th>
<th>Unit</th> <th>Unit</th>
<th style="text-align:center;">N/A?</th>
<th style="text-align:center;">VAT?</th>
<th style="text-align:right;">Unit Price (BD)</th> <th style="text-align:right;">Unit Price (BD)</th>
<th style="text-align:right;">Total (BD)</th> <th style="text-align:right;">Total (BD)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach($items as $i => $item) @foreach($items as $i => $item)
<tr> <tr id="row-{{ $i }}">
<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>
<td>{{ rtrim(rtrim(number_format((float)$item->quantity, 3), '0'), '.') }}</td> {{-- Display mode --}}
<div id="desc-display-{{ $i }}" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="desc-text-{{ $i }}" style="font-weight:500;">{{ $item->description }}</span>
<button type="button" onclick="editDesc({{ $i }})"
title="Edit item name"
style="flex-shrink:0;background:none;border:1px solid #e2e8f0;border-radius:5px;padding:2px 6px;cursor:pointer;color:#64748b;font-size:11px;line-height:1.4;"
onmouseover="this.style.borderColor='#2563eb';this.style.color='#2563eb'"
onmouseout="this.style.borderColor='#e2e8f0';this.style.color='#64748b'">
✎ Edit
</button>
<span id="desc-adj-{{ $i }}" style="display:none;font-size:10px;font-weight:700;background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:4px;border:1px solid #fde68a;">adjusted</span>
</div>
{{-- Edit mode --}}
<div id="desc-edit-{{ $i }}" style="display:none;align-items:center;gap:6px;">
<input type="text" id="desc-input-{{ $i }}"
value="{{ $item->description }}"
style="flex:1;min-width:0;padding:5px 8px;border:1.5px solid #2563eb;border-radius:6px;font-size:13px;font-weight:500;outline:none;width:auto;">
<button type="button" onclick="saveDesc({{ $i }}, {{ json_encode($item->description) }})"
style="flex-shrink:0;background:#2563eb;color:#fff;border:none;border-radius:5px;padding:4px 8px;cursor:pointer;font-size:12px;font-weight:700;">✓</button>
<button type="button" onclick="cancelDesc({{ $i }})"
style="flex-shrink:0;background:#f1f5f9;color:#64748b;border:1px solid #e2e8f0;border-radius:5px;padding:4px 8px;cursor:pointer;font-size:12px;">✕</button>
</div>
{{-- Hidden field submitted with form --}}
<input type="hidden" name="items[{{ $i }}][supplier_description]" id="desc-hidden-{{ $i }}" value="">
</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>
{{-- N/A checkbox --}}
<td style="text-align:center;">
<input type="checkbox" name="items[{{ $i }}][not_available]" id="na-{{ $i }}" value="1"
onchange="toggleNA({{ $i }}, {{ (float)$item->quantity_required }})"
style="width:16px;height:16px;accent-color:#dc2626;cursor:pointer;">
</td>
{{-- VAT checkbox --}}
<td style="text-align:center;">
@if($vatRate > 0)
<input type="checkbox" name="items[{{ $i }}][is_vatable]" id="vat-cb-{{ $i }}" value="1"
onchange="calcRow({{ $i }}, {{ (float)$item->quantity_required }})"
style="width:16px;height:16px;accent-color:#2563eb;cursor:pointer;">
@else
<span style="color:#cbd5e1;font-size:11px;">—</span>
@endif
</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" id="price-{{ $i }}" 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>
@@ -81,17 +143,25 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr style="background:#f8fafc;"> <tr style="background:#f8fafc;">
<td colspan="5" style="text-align:right;font-size:13px;color:#475569;">Grand Total:</td> <td colspan="7" style="text-align:right;font-size:13px;color:#475569;">Subtotal:</td>
<td style="text-align:right;font-size:15px;color:#2563eb;" id="grand-total">BD 0.000</td> <td style="text-align:right;font-size:14px;color:#475569;" id="subtotal-row">BD 0.000</td>
</tr>
<tr id="vat-tr" style="background:#eef2fb;{{ $vatRate > 0 ? '' : 'display:none;' }}">
<td colspan="7" style="text-align:right;font-size:13px;color:#3b5ea6;">VAT ({{ $vatRate }}%):</td>
<td style="text-align:right;font-size:14px;color:#3b5ea6;" id="vat-row">BD 0.000</td>
</tr>
<tr style="background:#f8fafc;border-top:2px solid #e2e8f0;">
<td colspan="7" style="text-align:right;font-size:13px;color:#475569;font-weight:700;">Grand Total:</td>
<td style="text-align:right;font-size:15px;color:#2563eb;font-weight:700;" id="grand-total">BD 0.000</td>
</tr> </tr>
</tfoot> </tfoot>
</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,32 +169,203 @@
<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>
</div> </div>
<script> <script>
var _totals = {}; var _vatRate = {{ $vatRate }};
var _totals = {};
var _vatable = {};
var _navail = {};
// ── Description inline edit ──────────────────────────────
function editDesc(i) {
document.getElementById('desc-display-' + i).style.display = 'none';
var ed = document.getElementById('desc-edit-' + i);
ed.style.display = 'flex';
document.getElementById('desc-input-' + i).focus();
}
function saveDesc(i, original) {
var val = document.getElementById('desc-input-' + i).value.trim();
if (!val) val = original;
document.getElementById('desc-text-' + i).textContent = val;
document.getElementById('desc-hidden-' + i).value = (val !== original) ? val : '';
var adj = document.getElementById('desc-adj-' + i);
adj.style.display = (val !== original) ? 'inline-block' : 'none';
document.getElementById('desc-edit-' + i).style.display = 'none';
document.getElementById('desc-display-' + i).style.display = 'flex';
}
function cancelDesc(i) {
var original = document.getElementById('desc-text-' + i).textContent;
document.getElementById('desc-input-' + i).value = original;
document.getElementById('desc-edit-' + i).style.display = 'none';
document.getElementById('desc-display-' + i).style.display = 'flex';
}
// ── N/A toggle ───────────────────────────────────────────
function toggleNA(i, qty) {
var naCb = document.getElementById('na-' + i);
var priceIn = document.getElementById('price-' + i);
var vatCb = document.getElementById('vat-cb-' + i);
var totEl = document.getElementById('tot-' + i);
var row = document.getElementById('row-' + i);
var na = naCb.checked;
_navail[i] = na;
if (na) {
priceIn.value = '';
priceIn.disabled = true;
priceIn.removeAttribute('required');
priceIn.style.opacity = '.35';
if (vatCb) { vatCb.checked = false; vatCb.disabled = true; vatCb.style.opacity = '.35'; }
totEl.innerHTML = '<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 7px;border-radius:4px;">Not available</span>';
row.style.opacity = '.6';
_totals[i] = 0;
_vatable[i] = false;
} else {
priceIn.disabled = false;
priceIn.setAttribute('required', '');
priceIn.style.opacity = '1';
if (vatCb) { vatCb.disabled = false; vatCb.style.opacity = '1'; }
totEl.textContent = '—';
row.style.opacity = '1';
_totals[i] = 0;
_vatable[i] = false;
calcRow(i, qty);
return;
}
recalcTotals();
}
// ── Price / VAT calculation ──────────────────────────────
function calcRow(i, qty) { function calcRow(i, qty) {
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]'); if (_navail[i]) return;
var inp = document.getElementById('price-' + i);
var cb = document.getElementById('vat-cb-' + i);
var up = parseFloat(inp ? inp.value : 0) || 0; var up = parseFloat(inp ? inp.value : 0) || 0;
var tot = Math.round(up * qty * 1000) / 1000; var tot = Math.round(up * qty * 1000) / 1000;
_totals[i] = tot; _totals[i] = tot;
_vatable[i] = cb ? cb.checked : false;
var el = document.getElementById('tot-' + i); var el = document.getElementById('tot-' + i);
if (el) el.textContent = 'BD ' + tot.toFixed(3); if (el) el.textContent = tot > 0 ? 'BD ' + tot.toFixed(3) : '—';
var grand = Object.values(_totals).reduce(function(a,b){return a+b;}, 0); recalcTotals();
var ge = document.getElementById('grand-total'); }
if (ge) ge.textContent = 'BD ' + grand.toFixed(3);
function recalcTotals() {
var subtotal = 0;
var vatAmount = 0;
Object.keys(_totals).forEach(function(i) {
if (_navail[i]) return;
subtotal += _totals[i];
if (_vatable[i] && _vatRate > 0) {
vatAmount += Math.round(_totals[i] * _vatRate / 100 * 1000) / 1000;
}
});
var grand = Math.round((subtotal + vatAmount) * 1000) / 1000;
var elSub = document.getElementById('subtotal-row');
var elVat = document.getElementById('vat-row');
var elGrand = document.getElementById('grand-total');
if (elSub) elSub.textContent = 'BD ' + subtotal.toFixed(3);
if (elVat) elVat.textContent = 'BD ' + vatAmount.toFixed(3);
if (elGrand) elGrand.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>
+76 -12
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>
<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 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> </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>
<div class="footer-bar">
<span style="font-size:11px;color:#cbd5e1;font-weight:500;letter-spacing:.03em;">SteelERP · Procurement Portal</span>
</div>
</div>
</body> </body>
</html> </html>
@@ -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>
+59
View File
@@ -0,0 +1,59 @@
@extends('layouts.app')
@section('title', 'Settings — VAT')
@section('content')
<div class="mb-5">
<h1 class="page-title">VAT Settings</h1>
<p class="page-subtitle">Set the global VAT rate applied to vatable items on supplier quotes.</p>
</div>
<div style="max-width:480px;">
<div style="background:white;border:1px solid #e2e8f0;border-radius:0.875rem;overflow:hidden;">
<div style="padding:1.25rem 1.5rem;border-bottom:1px solid #e2e8f0;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;">VAT Configuration</div>
</div>
<div style="padding:1.5rem;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;">
VAT Rate (%)
</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="number" id="vat-rate-input" value="{{ $vatRate }}"
min="0" max="100" step="0.01" placeholder="e.g. 10"
style="width:160px;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;font-weight:600;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
<span style="font-size:14px;color:#64748b;font-weight:500;">%</span>
</div>
<p style="font-size:12px;color:#94a3b8;margin-top:8px;">
Enter 0 to disable VAT. Suppliers will see a VAT checkbox on each item when this is greater than 0.
</p>
<button onclick="saveVat()"
style="margin-top:20px;padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">
Save VAT Rate
</button>
</div>
</div>
</div>
<script>
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function saveVat() {
var rate = document.getElementById('vat-rate-input').value;
fetch('{{ route('settings.vat.update') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_rate: rate })
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
}).then(function() {
showToast('VAT rate saved.', 'success');
}).catch(function(err) {
var msg = (err.errors && err.errors.vat_rate) ? err.errors.vat_rate[0] : (err.message || 'Failed to save.');
showToast(msg, 'error');
});
}
</script>
@endsection
+26
View File
@@ -29,6 +29,7 @@ use App\Http\Controllers\Sales\SalesOrderController;
use App\Http\Controllers\MailAccountController; use App\Http\Controllers\MailAccountController;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\Settings\ProjectSettingController; use App\Http\Controllers\Settings\ProjectSettingController;
use App\Http\Controllers\Settings\VatSettingController;
use App\Models\Settings\Location; use App\Models\Settings\Location;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -43,6 +44,27 @@ 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'] ?? '',
'go_url' => route('notifications.go', $n->id),
'ago' => $n->created_at->diffForHumans(),
]),
]))->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(
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');
@@ -149,6 +171,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('settings/projects/companies/{company}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.companies.departments.store'); Route::post('settings/projects/companies/{company}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.companies.departments.store');
Route::patch('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.companies.departments.update'); Route::patch('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.companies.departments.update');
Route::delete('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.companies.departments.destroy'); Route::delete('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.companies.departments.destroy');
// VAT settings
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
}); });
}); });