Compare commits
13
Commits
72e6c3170e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4c752800 | ||
|
|
30b2fb3958 | ||
|
|
50e5f3e381 | ||
|
|
d28e7330e6 | ||
|
|
716f0afcac | ||
|
|
6061e8ca4f | ||
|
|
7b399d5167 | ||
|
|
ee89269c0b | ||
|
|
a9db089a90 | ||
|
|
04ced4d2da | ||
|
|
b19396c996 | ||
|
|
3252cf7fb8 | ||
|
|
dca9cd5d99 |
No files matched your search
@@ -19,6 +19,8 @@
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/packages/azure-mailer/vendor
|
||||
/packages/ultra-message/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -35,7 +35,8 @@ class PurchasePipelineController extends Controller
|
||||
'items',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'supplierQuotes.supplier',
|
||||
'supplierQuotes.items',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ class RfqController extends Controller
|
||||
}
|
||||
|
||||
if (empty($supplierItems)) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Please assign at least one supplier to an item.'], 422);
|
||||
}
|
||||
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
|
||||
}
|
||||
|
||||
@@ -67,6 +70,22 @@ class RfqController extends Controller
|
||||
|
||||
$stages->setStage($purchaseRequest, 'rfq');
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
$invitations = $purchaseRequest->rfqInvitations()->with('supplier')->get()->map(function ($inv) {
|
||||
return [
|
||||
'supplier_name' => $inv->supplier->name,
|
||||
'channel' => $inv->channel,
|
||||
'url' => route('rfq.show', $inv->token),
|
||||
'status' => $inv->status,
|
||||
];
|
||||
});
|
||||
return response()->json([
|
||||
'added' => $added,
|
||||
'invitations' => $invitations,
|
||||
'redirect' => route('purchase.pipeline.show', $purchaseRequest),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\Setting;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Models\SupplierQuoteItem;
|
||||
use App\Models\User;
|
||||
use App\Notifications\QuoteReceived;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -35,9 +38,18 @@ class RfqPortalController extends Controller
|
||||
}
|
||||
|
||||
$purchaseRequest = $invitation->purchaseRequest;
|
||||
$items = $purchaseRequest->items;
|
||||
$itemIds = $invitation->item_ids;
|
||||
$items = $itemIds
|
||||
? $purchaseRequest->items->whereIn('id', $itemIds)->values()
|
||||
: $purchaseRequest->items;
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
|
||||
// Generate a fresh confirmation code per page load and store in session
|
||||
$confirmCode = strtoupper(substr(bin2hex(random_bytes(3)), 0, 5));
|
||||
session(['rfq_confirm_' . $token => $confirmCode]);
|
||||
|
||||
$vatRate = (float) Setting::get('vat_rate', 0);
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $token)
|
||||
@@ -49,14 +61,28 @@ class RfqPortalController extends Controller
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'terms' => ['accepted'],
|
||||
'confirm_code' => ['required', 'string'],
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
'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([
|
||||
'rfq_invitation_id' => $invitation->id,
|
||||
@@ -69,24 +95,40 @@ class RfqPortalController extends Controller
|
||||
'total_amount' => 0,
|
||||
]);
|
||||
|
||||
$total = 0;
|
||||
$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;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$total += $totalPrice;
|
||||
$notAvailable = !empty($validated['items'][$i]['not_available']);
|
||||
$unitPrice = $notAvailable ? 0 : (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity_required;
|
||||
$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([
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'supplier_description'=> $supplierDescription,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'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']);
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Notify all admin users
|
||||
$invitation->load('supplier', 'purchaseRequest');
|
||||
User::role('Admin')->each(fn($u) => $u->notify(new QuoteReceived($invitation)));
|
||||
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ class ProjectSettingController extends Controller
|
||||
|
||||
$parts = [];
|
||||
if ($stats['projects_created']) $parts[] = "{$stats['projects_created']} project(s)";
|
||||
if ($stats['locations_created']) $parts[] = "{$stats['locations_created']} location(s)";
|
||||
if ($stats['departments_created']) $parts[] = "{$stats['departments_created']} department(s)";
|
||||
if ($stats['companies_created']) $parts[] = "{$stats['companies_created']} new company(s)";
|
||||
|
||||
@@ -220,29 +221,41 @@ class ProjectSettingController extends Controller
|
||||
'font' => ['italic' => true, 'color' => ['rgb' => '64748b'], 'size' => 10],
|
||||
];
|
||||
|
||||
// ── Sheet 1: Projects ──────────────────────────────────────────────
|
||||
// ── Sheet 1: Projects (with optional locations) ────────────────────
|
||||
$s1 = $spreadsheet->getActiveSheet()->setTitle('Projects');
|
||||
$s1->setCellValue('A1', 'Company Name')
|
||||
->setCellValue('B1', 'Project Name');
|
||||
$s1->getStyle('A1:B1')->applyFromArray($headerStyle);
|
||||
->setCellValue('B1', 'Project Name')
|
||||
->setCellValue('C1', 'Location Name')
|
||||
->setCellValue('D1', 'Address')
|
||||
->setCellValue('E1', 'Latitude')
|
||||
->setCellValue('F1', 'Longitude');
|
||||
$s1->getStyle('A1:F1')->applyFromArray($headerStyle);
|
||||
|
||||
// Sample rows
|
||||
$samples = [
|
||||
['Miknas Industrial', 'New Warehouse'],
|
||||
['Steel tech', 'Factory Extension'],
|
||||
['Steel tech', 'New Office Block'],
|
||||
['Miknas Industrial', 'New Warehouse', 'Main Gate', 'Industrial Area, Block 5', '24.7136', '46.6753'],
|
||||
['Miknas Industrial', 'New Warehouse', 'Storage Yard', '', '', ''],
|
||||
['Steel tech', 'Factory Extension','Site Office', '2nd Ring Road, Riyadh', '24.6877', '46.7219'],
|
||||
['Steel tech', 'New Office Block', '', '', '', ''],
|
||||
];
|
||||
foreach ($samples as $i => $row) {
|
||||
$s1->setCellValue('A' . ($i + 2), $row[0]);
|
||||
$s1->setCellValue('B' . ($i + 2), $row[1]);
|
||||
$s1->setCellValue('C' . ($i + 2), $row[2]);
|
||||
$s1->setCellValue('D' . ($i + 2), $row[3]);
|
||||
$s1->setCellValue('E' . ($i + 2), $row[4]);
|
||||
$s1->setCellValue('F' . ($i + 2), $row[5]);
|
||||
}
|
||||
|
||||
$s1->setCellValue('A6', '* Delete sample rows before importing. Company will be created if it does not exist.');
|
||||
$s1->getStyle('A6')->applyFromArray($noteStyle);
|
||||
$s1->mergeCells('A6:B6');
|
||||
$s1->setCellValue('A7', '* Leave Location Name blank for rows that are projects only. Company is created automatically. Duplicates are skipped.');
|
||||
$s1->getStyle('A7')->applyFromArray($noteStyle);
|
||||
$s1->mergeCells('A7:F7');
|
||||
|
||||
$s1->getColumnDimension('A')->setWidth(32);
|
||||
$s1->getColumnDimension('B')->setWidth(32);
|
||||
$s1->getColumnDimension('A')->setWidth(28);
|
||||
$s1->getColumnDimension('B')->setWidth(28);
|
||||
$s1->getColumnDimension('C')->setWidth(24);
|
||||
$s1->getColumnDimension('D')->setWidth(32);
|
||||
$s1->getColumnDimension('E')->setWidth(14);
|
||||
$s1->getColumnDimension('F')->setWidth(14);
|
||||
|
||||
// ── Sheet 2: Departments ───────────────────────────────────────────
|
||||
$s2 = new Worksheet($spreadsheet, 'Departments');
|
||||
|
||||
@@ -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,7 +7,13 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class SupplierQuoteItem extends Model
|
||||
{
|
||||
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()
|
||||
|
||||
+4
-1
@@ -41,8 +41,11 @@ class User extends Authenticatable
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function routeNotificationFor(string $channel, mixed $notification = null): ?string
|
||||
public function routeNotificationFor(string $channel, mixed $notification = null): mixed
|
||||
{
|
||||
if ($channel === 'database') {
|
||||
return $this->notifications();
|
||||
}
|
||||
return $this->whatsapp_number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\RfqInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class QuoteReceived extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public RfqInvitation $invitation) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$pr = $this->invitation->purchaseRequest;
|
||||
return [
|
||||
'type' => 'quote_received',
|
||||
'message' => $this->invitation->supplier->name . ' submitted a quote for ' . $pr->request_number,
|
||||
'supplier_name' => $this->invitation->supplier->name,
|
||||
'request_number' => $pr->request_number,
|
||||
'purchase_request_id' => $pr->id,
|
||||
'url' => route('purchase.pipeline.show', $pr),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class ProjectImportService
|
||||
private array $stats = [
|
||||
'companies_created' => 0,
|
||||
'projects_created' => 0,
|
||||
'locations_created' => 0,
|
||||
'departments_created' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
@@ -56,8 +57,12 @@ class ProjectImportService
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
$headers = $this->normalizeHeaders((array) array_shift($rows));
|
||||
|
||||
$coIdx = $this->findCol($headers, ['company', 'company name', 'company name', 'companyname']);
|
||||
$projIdx = $this->findCol($headers, ['project', 'project name', 'project name', 'projectname']);
|
||||
$coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']);
|
||||
$projIdx = $this->findCol($headers, ['project', 'project name', 'projectname']);
|
||||
$locIdx = $this->findCol($headers, ['location', 'location name', 'locationname', 'loc', 'loc name']);
|
||||
$addrIdx = $this->findCol($headers, ['address', 'addr']);
|
||||
$latIdx = $this->findCol($headers, ['latitude', 'lat']);
|
||||
$lngIdx = $this->findCol($headers, ['longitude', 'lng', 'lon', 'long']);
|
||||
|
||||
if ($coIdx === null || $projIdx === null) {
|
||||
return;
|
||||
@@ -73,16 +78,37 @@ class ProjectImportService
|
||||
}
|
||||
|
||||
$company = $this->findOrCreateCompany($coName);
|
||||
$existing = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
|
||||
$project = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
|
||||
->where('company_id', $company->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->stats['skipped']++;
|
||||
continue;
|
||||
if (!$project) {
|
||||
$project = ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
|
||||
$this->stats['projects_created']++;
|
||||
}
|
||||
|
||||
ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
|
||||
$this->stats['projects_created']++;
|
||||
// If a location name is present on this row, import it too
|
||||
$locName = $locIdx !== null ? $this->str($row[$locIdx] ?? null) : null;
|
||||
if ($locName) {
|
||||
$existingLoc = $project->locations()
|
||||
->whereRaw('LOWER(name) = ?', [strtolower($locName)])->first();
|
||||
|
||||
if ($existingLoc) {
|
||||
$this->stats['skipped']++;
|
||||
} else {
|
||||
$address = $addrIdx !== null ? $this->str($row[$addrIdx] ?? null) : null;
|
||||
$lat = $latIdx !== null ? $this->str($row[$latIdx] ?? null) : null;
|
||||
$lng = $lngIdx !== null ? $this->str($row[$lngIdx] ?? null) : null;
|
||||
|
||||
$project->locations()->create([
|
||||
'name' => $locName,
|
||||
'address' => $address,
|
||||
'latitude' => is_numeric($lat) ? (float) $lat : null,
|
||||
'longitude' => is_numeric($lng) ? (float) $lng : null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->stats['locations_created']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -8,11 +8,11 @@
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../ultra-message"
|
||||
"url": "./packages/ultra-message"
|
||||
},
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../azure-mailer"
|
||||
"url": "./packages/azure-mailer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
|
||||
Generated
+4
-4
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5daed94f08a2aff0bba86affd37a0cd6",
|
||||
"content-hash": "644cad003bc58ba3ea106336e06e0396",
|
||||
"packages": [
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
@@ -3283,8 +3283,8 @@
|
||||
"version": "dev-master",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "../azure-mailer",
|
||||
"reference": "d240f4f64afcd094b30cd460b82f809f3b2003f9"
|
||||
"url": "./packages/azure-mailer",
|
||||
"reference": "b6d04a3e3a01a8439853efe8f52f672ae8a04b75"
|
||||
},
|
||||
"require": {
|
||||
"illuminate/cache": "^11.0|^12.0",
|
||||
@@ -3334,7 +3334,7 @@
|
||||
"version": "dev-master",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "../ultra-message",
|
||||
"url": "./packages/ultra-message",
|
||||
"reference": "56160235808f7d9d9dd3ec8d0eefa20a17807647"
|
||||
},
|
||||
"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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+26
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
composer.lock
|
||||
*.cache
|
||||
@@ -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
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/vendor/
|
||||
composer.lock
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.{{ $prefix }}-urgency-opt:hover { background:#f8fafc; }
|
||||
</style>
|
||||
|
||||
{{-- ── Trigger button ── --}}
|
||||
<button type="button" onclick="{{ $prefix }}Open()" class="btn-secondary btn-sm">
|
||||
Edit
|
||||
@@ -119,9 +123,67 @@ $curProjectInList = $editProjects->contains('name', $curProject);
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
<div style="position:relative;" id="{{ $prefix }}UrgencyWrapper">
|
||||
<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;">▼</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>
|
||||
<label class="form-label">Location / Site</label>
|
||||
@@ -315,6 +377,87 @@ $curProjectInList = $editProjects->contains('name', $curProject);
|
||||
}
|
||||
window.{{ $prefix }}FilterLoc = {{ $prefix }}FilterLoc;
|
||||
|
||||
// ── Urgency colored dropdown ──────────────────────────────────────────────
|
||||
var _urgencyMap{{ $prId }} = {
|
||||
'Urgent': ['Urgent', '#dc2626','#fef2f2'],
|
||||
'3 Days': ['3 Days', '#ea580c','#fff7ed'],
|
||||
'1 Week': ['1 Week', '#d97706','#fffbeb'],
|
||||
'2 Weeks': ['2 Weeks', '#2563eb','#eff6ff'],
|
||||
'1 Month': ['1 Month', '#16a34a','#f0fdf4'],
|
||||
};
|
||||
|
||||
function _urgencyBadge{{ $prId }}(label, color, bgColor) {
|
||||
return '<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
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var curProj = document.getElementById('{{ $prefix }}Project').value;
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
@php
|
||||
$hasErrors = $errors->any();
|
||||
$mprProjects = \App\Models\Settings\ProjectSetting::active()
|
||||
->with(['locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); }])
|
||||
->whereNotNull('company_id')
|
||||
->with([
|
||||
'company',
|
||||
'locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); },
|
||||
])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$mprProjectsJson = $mprProjects->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'company_id' => $p->company_id,
|
||||
'company_name' => $p->company ? $p->company->name : '',
|
||||
'label' => ($p->company ? $p->company->name . ' — ' : '') . $p->name,
|
||||
'locations' => $p->locations->map(function ($l) {
|
||||
return ['name' => $l->name];
|
||||
})->values(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$mprDepartments = \App\Models\Settings\Department::where('is_active', true)->orderBy('name')->get();
|
||||
$mprDepartmentsJson = $mprDepartments->map(fn($d) => ['id' => $d->id, 'name' => $d->name, 'company_id' => $d->company_id])->values();
|
||||
|
||||
$mprUnits = ['PCS','NOS','KG','TON','MTR','SQM','LTR','BAG','BOX','ROLL','SET','EA','CANS','LOT'];
|
||||
$today = date('Y-m-d');
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.mpr-proj-opt:hover { background:#eff6ff; border-left-color:#2563eb !important; }
|
||||
.mpr-urgency-opt:hover { background:#f8fafc; }
|
||||
</style>
|
||||
|
||||
{{-- Trigger button --}}
|
||||
<button type="button" onclick="mprModalOpen()" class="btn-primary">
|
||||
+ 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>
|
||||
<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 style="position:relative;" id="mpr-project-wrapper">
|
||||
<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)">
|
||||
<option value="">— Select Project —</option>
|
||||
{{-- Hidden input carries the actual value for form submission --}}
|
||||
<input type="hidden" name="project_name" id="mpr-project-value" value="{{ old('project_name') }}">
|
||||
{{-- Trigger --}}
|
||||
<button type="button" id="mpr-project-trigger"
|
||||
onclick="mprProjectToggle()"
|
||||
style="width:100%;text-align:left;background:white;border:1px solid #d1d5db;border-radius:0.375rem;padding:0.4rem 2rem 0.4rem 0.625rem;font-size:0.875rem;color:#111827;cursor:pointer;position:relative;line-height:1.5;min-height:2.25rem;">
|
||||
<span id="mpr-project-label" style="color:#9ca3af;">— Select Project —</span>
|
||||
<span style="position:absolute;right:0.5rem;top:50%;transform:translateY(-50%);pointer-events:none;color:#6b7280;">▼</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)
|
||||
<option value="{{ $proj->name }}" {{ old('project_name') == $proj->name ? 'selected' : '' }} data-id="{{ $proj->id }}">{{ $proj->name }}</option>
|
||||
<li class="mpr-proj-opt"
|
||||
data-value="{{ $proj->name }}"
|
||||
data-label="{{ ($proj->company ? $proj->company->name . ' — ' : '') . $proj->name }}"
|
||||
data-search="{{ strtolower(($proj->company ? $proj->company->name . ' ' : '') . $proj->name) }}"
|
||||
onclick="mprProjectSelect('{{ $proj->name }}', '{{ addslashes(($proj->company ? $proj->company->name . ' — ' : '') . $proj->name) }}')"
|
||||
style="padding:0.45rem 0.875rem;cursor:pointer;font-size:0.8rem;color:#111827;line-height:1.4;border-left:3px solid transparent;">
|
||||
<span style="font-size:0.68rem;color:#6b7280;display:block;line-height:1.2;">{{ $proj->company ? $proj->company->name : '' }}</span>
|
||||
{{ $proj->name }}
|
||||
</li>
|
||||
@endforeach
|
||||
</select>
|
||||
<li id="mpr-proj-empty" style="display:none;padding:0.625rem 0.875rem;font-size:0.8rem;color:#9ca3af;">No projects found.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
<div style="position:relative;" id="mpr-urgency-wrapper">
|
||||
<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;">▼</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>
|
||||
<label class="form-label">Location / Site</label>
|
||||
@@ -99,7 +232,12 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
@@ -119,7 +257,7 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
|
||||
<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;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: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>
|
||||
@@ -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>
|
||||
</td>
|
||||
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
|
||||
<input type="text" name="items[{{ $idx }}][unit]" value="{{ $oldItem['unit'] ?? '' }}"
|
||||
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="PCS…">
|
||||
<select name="items[{{ $idx }}][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;cursor:pointer;">
|
||||
<option value="">—</option>
|
||||
@foreach($mprUnits as $u)
|
||||
<option value="{{ $u }}" {{ ($oldItem['unit'] ?? '') == $u ? 'selected' : '' }}>{{ $u }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</td>
|
||||
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
|
||||
<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…">
|
||||
</td>
|
||||
<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;">
|
||||
</td>
|
||||
<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(); });
|
||||
@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
|
||||
(function () {
|
||||
var mprRowIndex = {{ count($oldItems ?? [[]]) }};
|
||||
@@ -214,18 +363,26 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
|
||||
});
|
||||
}
|
||||
|
||||
function mprLastRowDate() {
|
||||
var rows = document.querySelectorAll('#mpr-items-body .mpr-item-row');
|
||||
if (!rows.length) return '{{ $today }}';
|
||||
var lastDate = rows[rows.length - 1].querySelector('input[type="date"]');
|
||||
return (lastDate && lastDate.value) ? lastDate.value : '{{ $today }}';
|
||||
}
|
||||
|
||||
function mprNewRow() {
|
||||
var idx = mprRowIndex++;
|
||||
var date = mprLastRowDate();
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'mpr-item-row';
|
||||
tr.style.background = 'white';
|
||||
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.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="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\'">×</button></td>';
|
||||
return tr;
|
||||
}
|
||||
@@ -253,11 +410,61 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
|
||||
});
|
||||
})();
|
||||
|
||||
// Cascading project → location dropdown
|
||||
// ── Cascading data ────────────────────────────────────────────────────────────
|
||||
var mprProjectsData = @json($mprProjectsJson);
|
||||
var mprDepartmentsData = @json($mprDepartmentsJson);
|
||||
var mprOldLocation = "{{ old('location') }}";
|
||||
var mprOldProjectName = "{{ old('project_name') }}";
|
||||
var mprOldDepartment = "{{ old('department') }}";
|
||||
|
||||
// ── Searchable project dropdown ───────────────────────────────────────────────
|
||||
function mprProjectToggle() {
|
||||
var panel = document.getElementById('mpr-project-panel');
|
||||
var open = panel.style.display !== 'none';
|
||||
if (open) {
|
||||
mprProjectClose();
|
||||
} else {
|
||||
panel.style.display = 'block';
|
||||
var search = document.getElementById('mpr-project-search');
|
||||
search.value = '';
|
||||
mprProjectSearch('');
|
||||
setTimeout(function(){ search.focus(); }, 30);
|
||||
}
|
||||
}
|
||||
|
||||
function mprProjectClose() {
|
||||
document.getElementById('mpr-project-panel').style.display = 'none';
|
||||
}
|
||||
|
||||
function mprProjectSearch(q) {
|
||||
var items = document.querySelectorAll('#mpr-project-list .mpr-proj-opt');
|
||||
var empty = document.getElementById('mpr-proj-empty');
|
||||
var term = q.trim().toLowerCase();
|
||||
var visible = 0;
|
||||
items.forEach(function(li) {
|
||||
var match = !term || li.dataset.search.indexOf(term) !== -1;
|
||||
li.style.display = match ? '' : 'none';
|
||||
if (match) visible++;
|
||||
});
|
||||
empty.style.display = visible === 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
function mprProjectSelect(value, label) {
|
||||
document.getElementById('mpr-project-value').value = value;
|
||||
var lbl = document.getElementById('mpr-project-label');
|
||||
lbl.textContent = label;
|
||||
lbl.style.color = '#111827';
|
||||
mprProjectClose();
|
||||
mprOnProjectChange(value);
|
||||
}
|
||||
|
||||
// Close panel when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
var wrapper = document.getElementById('mpr-project-wrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) mprProjectClose();
|
||||
});
|
||||
|
||||
// ── Location cascade ──────────────────────────────────────────────────────────
|
||||
function mprFilterLocations(projectName) {
|
||||
var sel = document.getElementById('mpr-location-select');
|
||||
sel.innerHTML = '<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(){
|
||||
if (mprOldProjectName) {
|
||||
mprFilterLocations(mprOldProjectName);
|
||||
var proj = mprProjectsData.find(function(p){ return p.name === mprOldProjectName; });
|
||||
if (proj) mprProjectSelect(proj.name, proj.label);
|
||||
} else {
|
||||
document.getElementById('mpr-location-select').disabled = true;
|
||||
mprFilterDepartments('');
|
||||
}
|
||||
// Restore urgency pill
|
||||
var oldUrgency = document.getElementById('mpr-urgency-value').value;
|
||||
if (oldUrgency) mprUrgencyRestore(oldUrgency);
|
||||
});
|
||||
|
||||
// ── Urgency colored dropdown ──────────────────────────────────────────────────
|
||||
var mprUrgencyPresets = ['Urgent','3 Days','1 Week','2 Weeks','1 Month'];
|
||||
|
||||
function mprUrgencyToggle() {
|
||||
var panel = document.getElementById('mpr-urgency-panel');
|
||||
if (panel.style.display === 'none') {
|
||||
panel.style.display = 'block';
|
||||
} else {
|
||||
mprUrgencyClose();
|
||||
}
|
||||
}
|
||||
|
||||
function mprUrgencyClose() {
|
||||
document.getElementById('mpr-urgency-panel').style.display = 'none';
|
||||
}
|
||||
|
||||
function mprUrgencySelect(value, label, color, bgColor) {
|
||||
document.getElementById('mpr-urgency-value').value = value;
|
||||
// Update trigger display
|
||||
var display = document.getElementById('mpr-urgency-display');
|
||||
display.innerHTML =
|
||||
'<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>
|
||||
@@ -256,18 +256,15 @@
|
||||
|
||||
</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-summary-body" style="flex:1;overflow-y:auto;overscroll-behavior:contain;padding:20px 24px;">
|
||||
</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;">
|
||||
<button type="button" onclick="backToEdit()"
|
||||
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;">
|
||||
← Back to edit
|
||||
</button>
|
||||
<button type="button" onclick="document.getElementById('sup-form').submit()"
|
||||
<div style="font-size:12px;color:#64748b;" id="sup-link-count"></div>
|
||||
<button type="button" onclick="doneWithLinks()"
|
||||
style="padding:8px 22px;background:#16a34a;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">
|
||||
Confirm & Send →
|
||||
Done ✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,69 +517,117 @@ function updateFooter() {
|
||||
}
|
||||
}
|
||||
|
||||
var _rfqRedirect = null;
|
||||
|
||||
function submitSuppliers() {
|
||||
if (_supTab === 'global') {
|
||||
var checked = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])');
|
||||
if (checked.length === 0) { showToast('Please select at least one supplier.', 'warn'); return; }
|
||||
document.getElementById('sup-form').submit();
|
||||
} else {
|
||||
var checked = document.querySelectorAll('input[name^="item_suppliers["]:checked:not([disabled])');
|
||||
if (checked.length === 0) { showToast('Please assign at least one supplier to an item.', 'warn'); return; }
|
||||
showSummary();
|
||||
}
|
||||
|
||||
// Show step 3 with loading state immediately
|
||||
document.getElementById('sup-summary-body').innerHTML =
|
||||
'<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) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function showSummary() {
|
||||
var chanLabel = {
|
||||
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:12px;font-weight:700;">WhatsApp</span>',
|
||||
both: '<span style="background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">Email + WA</span>',
|
||||
var _chanBadge = {
|
||||
email: '<span style="background:#eff6ff;color:#2563eb;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;flex-shrink:0;">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>',
|
||||
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>',
|
||||
};
|
||||
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-"]');
|
||||
btns.forEach(function(btn) {
|
||||
var itemId = btn.id.replace('idd-btn-', '');
|
||||
var checked = document.querySelectorAll('input[name="item_suppliers[' + itemId + '][]"]:checked:not([disabled])');
|
||||
if (checked.length === 0) return;
|
||||
|
||||
var name = btn.dataset.itemname || ('Item ' + itemId);
|
||||
var qty = btn.dataset.itemqty || '';
|
||||
|
||||
html += '<div style="margin-bottom:12px;border:1.5px solid #e2e8f0;border-radius:10px;overflow:hidden;">';
|
||||
html += '<div style="padding:10px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;">';
|
||||
html += '<div style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(name) + '</div>';
|
||||
if (qty) html += '<div style="font-size:11px;color:#94a3b8;margin-top:1px;">Qty: ' + escHtml(qty) + '</div>';
|
||||
function showLinks(invitations) {
|
||||
var html = '';
|
||||
if (!invitations || invitations.length === 0) {
|
||||
html = '<div style="padding:30px;text-align:center;color:#94a3b8;font-size:13px;">No new invitations were created.</div>';
|
||||
} 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>';
|
||||
invitations.forEach(function(inv, i) {
|
||||
var badge = _chanBadge[inv.channel] || '';
|
||||
html += '<div style="margin-bottom:10px;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 += '<span style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(inv.supplier_name) + '</span>';
|
||||
html += badge;
|
||||
html += '</div>';
|
||||
html += '<div style="padding:10px 14px;display:flex;align-items:center;gap:8px;">';
|
||||
html += '<a href="' + escHtml(inv.url) + '" target="_blank"'
|
||||
+ ' style="flex:1;display:flex;align-items:center;gap:8px;padding:9px 14px;'
|
||||
+ 'background:#eff6ff;border:1.5px solid #bfdbfe;border-radius:8px;text-decoration:none;'
|
||||
+ 'color:#1d4ed8;font-size:12px;font-weight:600;transition:background .15s;overflow:hidden;"'
|
||||
+ ' onmouseover="this.style.background=\'#dbeafe\'" onmouseout="this.style.background=\'#eff6ff\'">'
|
||||
+ '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;">'
|
||||
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>'
|
||||
+ '</svg>'
|
||||
+ '<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Open Quote Link</span>'
|
||||
+ '</a>';
|
||||
html += '<button type="button" data-idx="' + i + '" data-url="' + escHtml(inv.url) + '" onclick="copyLink(this)"'
|
||||
+ ' style="flex-shrink:0;padding:9px 14px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;">Copy Link</button>';
|
||||
html += '</div>';
|
||||
|
||||
checked.forEach(function(cb) {
|
||||
var supName = cb.dataset.supname || cb.value;
|
||||
var chan = (document.getElementById('ichan-val-' + cb.value) || {}).value || 'email';
|
||||
var cl = chanLabel[chan] || '<span style="background:#f1f5f9;color:#475569;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">' + escHtml(chan) + '</span>';
|
||||
html += '<div style="padding:9px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #f8fafc;">';
|
||||
html += '<span style="font-size:13px;font-weight:600;color:#0f172a;">' + escHtml(supName) + '</span>';
|
||||
html += cl;
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('sup-summary-body').innerHTML = html;
|
||||
document.getElementById('sup-modal-title').textContent = 'Confirm Assignments';
|
||||
document.getElementById('sup-modal-subtitle').textContent = 'Review before sending to suppliers';
|
||||
document.getElementById('sup-step2').style.display = 'none';
|
||||
document.getElementById('sup-step3').style.display = 'flex';
|
||||
document.getElementById('sup-modal-title').textContent = 'Quote Links Ready';
|
||||
document.getElementById('sup-modal-subtitle').textContent = 'Share with suppliers via their preferred channel';
|
||||
var countEl = document.getElementById('sup-link-count');
|
||||
if (countEl) countEl.textContent = invitations.length + ' link' + (invitations.length === 1 ? '' : 's') + ' generated';
|
||||
}
|
||||
|
||||
function backToEdit() {
|
||||
document.getElementById('sup-modal-title').textContent = 'Select Suppliers';
|
||||
document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request';
|
||||
document.getElementById('sup-step3').style.display = 'none';
|
||||
document.getElementById('sup-step2').style.display = 'flex';
|
||||
function copyLink(btn) {
|
||||
var url = btn.dataset.url;
|
||||
if (!url) return;
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#16a34a';
|
||||
setTimeout(function() { btn.textContent = 'Copy'; btn.style.background = '#2563eb'; }, 2000);
|
||||
}).catch(function() {
|
||||
// Fallback for older browsers
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); btn.textContent = 'Copied!'; btn.style.background = '#16a34a';
|
||||
setTimeout(function() { btn.textContent = 'Copy'; btn.style.background = '#2563eb'; }, 2000);
|
||||
} catch(e) { showToast('Could not copy — please copy manually.', 'warn'); }
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
}
|
||||
|
||||
function doneWithLinks() {
|
||||
if (_rfqRedirect) { window.location.href = _rfqRedirect; }
|
||||
else { window.location.reload(); }
|
||||
}
|
||||
</script>
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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 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'])
|
||||
@@ -197,6 +199,17 @@
|
||||
</svg>
|
||||
Integrations
|
||||
</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
|
||||
|
||||
<div style="height:16px;"></div>
|
||||
@@ -249,6 +262,38 @@
|
||||
|
||||
<div style="display:flex;align-items:center;gap:16px;">
|
||||
<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="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;">
|
||||
@@ -493,6 +538,77 @@ document.addEventListener('keydown', function(e) {
|
||||
});
|
||||
</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>
|
||||
function toggleSidebar() {
|
||||
var s = document.getElementById('sidebar');
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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 -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
|
||||
@@ -300,23 +300,39 @@
|
||||
Quotes ({{ $pr->supplierQuotes->count() }})
|
||||
</h3>
|
||||
<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)
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:8px 10px;border-radius:8px;
|
||||
background:{{ $quote->is_awarded ? '#f0fdf4' : '#f8fafc' }};
|
||||
border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : '#f1f5f9' }};">
|
||||
@php $isLowest = !$quote->is_awarded && $pr->supplierQuotes->count() > 1 && (float)$quote->total_amount === (float)$minTotal; @endphp
|
||||
<div onclick="openQuoteModal({{ $quote->id }})"
|
||||
style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:box-shadow .15s,border-color .15s;
|
||||
background:{{ $quote->is_awarded ? '#f0fdf4' : ($isLowest ? '#eff6ff' : '#f8fafc') }};
|
||||
border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }};"
|
||||
onmouseenter="this.style.boxShadow='0 2px 8px rgba(0,0,0,.08)';this.style.borderColor='{{ $quote->is_awarded ? '#86efac' : ($isLowest ? '#93c5fd' : '#e2e8f0') }}';"
|
||||
onmouseleave="this.style.boxShadow='';this.style.borderColor='{{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }}';">
|
||||
<div>
|
||||
<div style="font-weight:600;color:#0f172a;">{{ $quote->supplier->name }}</div>
|
||||
<div style="color:{{ $quote->is_awarded ? '#15803d' : '#374151' }};font-weight:700;">
|
||||
{{ number_format($quote->total_amount, 2) }}
|
||||
@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)
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<a href="{{ route('purchase.requests.quotes', $pr) }}"
|
||||
style="display:block;margin-top:12px;text-align:center;font-size:12px;color:#2563eb;text-decoration:none;font-weight:600;">
|
||||
View All Quotes →
|
||||
<a href="{{ route('purchase.requests.compare', $pr) }}"
|
||||
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;">
|
||||
Compare All Quotes →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@@ -401,6 +417,202 @@
|
||||
|
||||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ---- Signature modal ----
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
{{-- Per-item rows --}}
|
||||
@foreach($items as $i => $reqItem)
|
||||
@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;
|
||||
@endphp
|
||||
<tr>
|
||||
@@ -57,14 +57,24 @@
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Qty: {{ $reqItem->quantity }} {{ $reqItem->unit }}</div>
|
||||
</td>
|
||||
@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' : '' }};">
|
||||
@if($qItem)
|
||||
@if($qItem->not_available)
|
||||
<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>
|
||||
@else
|
||||
@if($qItem->supplier_description)
|
||||
<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
|
||||
<span style="color:#e2e8f0;">—</span>
|
||||
@endif
|
||||
|
||||
@@ -6,31 +6,50 @@
|
||||
<title>Quote Request — {{ $purchaseRequest->request_number }}</title>
|
||||
<style>
|
||||
*{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;}
|
||||
.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;}
|
||||
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;}
|
||||
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;}
|
||||
.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;}
|
||||
thead th{background:#f8fafc;padding:10px 12px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
|
||||
tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
|
||||
tfoot td{padding:12px;font-weight:700;}
|
||||
input[type=number].price{width:130px;text-align:right;}
|
||||
@media(max-width:600px){
|
||||
.body{padding:20px;}
|
||||
thead th{background:#f8fafc;padding:11px 16px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
|
||||
tbody td{padding:12px 16px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
|
||||
tfoot td{padding:13px 16px;font-weight:700;}
|
||||
input[type=number].price{width:140px;text-align:right;}
|
||||
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;}
|
||||
|
||||
/* ── 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;}
|
||||
thead{display:none;}
|
||||
tbody td{border:none;padding:4px 0;}
|
||||
tbody tr{border:1px solid #e2e8f0;border-radius:10px;padding:12px;margin-bottom:10px;}
|
||||
tbody td{border:none;padding:3px 0;}
|
||||
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%;}
|
||||
/* Confirm code stacks */
|
||||
.code-row{flex-direction:column !important;align-items:stretch !important;}
|
||||
.code-display{display:block;text-align:center;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
|
||||
{{-- Header --}}
|
||||
<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:22px;font-weight:700;color:#fff;margin-top:4px;">{{ $purchaseRequest->request_number }}</div>
|
||||
@@ -59,21 +78,64 @@
|
||||
<th>Description</th>
|
||||
<th>Qty</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;">Total (BD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($items as $i => $item)
|
||||
<tr>
|
||||
<tr id="row-{{ $i }}">
|
||||
<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, 3), '0'), '.') }}</td>
|
||||
<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>
|
||||
{{-- 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;">
|
||||
<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"
|
||||
oninput="calcRow({{ $i }}, {{ (float)$item->quantity }})">
|
||||
oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:600;" id="tot-{{ $i }}">—</td>
|
||||
</tr>
|
||||
@@ -81,17 +143,25 @@
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="background:#f8fafc;">
|
||||
<td colspan="5" style="text-align:right;font-size:13px;color:#475569;">Grand Total:</td>
|
||||
<td style="text-align:right;font-size:15px;color:#2563eb;" id="grand-total">BD 0.000</td>
|
||||
<td colspan="7" 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:#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>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Terms --}}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
|
||||
{{-- Delivery / Payment --}}
|
||||
<div class="two-col">
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
@@ -99,32 +169,203 @@
|
||||
<input type="text" name="payment_terms" placeholder="e.g. 30 days net">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom:20px;">
|
||||
<label class="fl">Notes / Remarks</label>
|
||||
<textarea name="notes" rows="3" placeholder="Any additional notes, conditions, or remarks…"></textarea>
|
||||
</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 & 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>
|
||||
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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) {
|
||||
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 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 = 'BD ' + tot.toFixed(3);
|
||||
var grand = Object.values(_totals).reduce(function(a,b){return a+b;}, 0);
|
||||
var ge = document.getElementById('grand-total');
|
||||
if (ge) ge.textContent = 'BD ' + grand.toFixed(3);
|
||||
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) {
|
||||
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>
|
||||
</body>
|
||||
|
||||
@@ -3,25 +3,89 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Quote Submitted</title>
|
||||
<title>Quote Submitted — Thank You</title>
|
||||
<style>
|
||||
*{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;}
|
||||
.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;}
|
||||
body{font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}
|
||||
@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>
|
||||
</head>
|
||||
<body>
|
||||
<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="font-size:14px;color:#475569;line-height:1.65;">
|
||||
Thank you, <strong>{{ $invitation->supplier->name }}</strong>. Your quote for
|
||||
<strong>{{ $invitation->purchaseRequest->request_number }}</strong> has been received
|
||||
and is under review. You will be contacted if you are selected.
|
||||
|
||||
<div style="height:6px;background:linear-gradient(90deg,#16a34a,#22c55e,#4ade80);"></div>
|
||||
|
||||
<div class="inner">
|
||||
|
||||
{{-- 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 style="margin-top:28px;padding:14px;background:#f0fdf4;border-radius:10px;font-size:12px;color:#64748b;">
|
||||
Submitted on {{ now()->format('d M Y, H:i') }}
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#16a34a;margin-bottom:8px;">Quote Received</div>
|
||||
<h1 style="font-size:26px;font-weight:800;color:#0f172a;margin-bottom:14px;line-height:1.25;">
|
||||
Thank you,<br>{{ $invitation->supplier->name }}!
|
||||
</h1>
|
||||
<p style="font-size:15px;color:#475569;line-height:1.75;max-width:360px;margin:0 auto 24px;">
|
||||
Your quote for <strong style="color:#0f172a;">{{ $invitation->purchaseRequest->request_number }}</strong>
|
||||
has been successfully received. Our team will review all submitted quotes and get back to you shortly.
|
||||
</p>
|
||||
|
||||
{{-- Details --}}
|
||||
<div style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;padding:4px 20px;text-align:left;margin-bottom:24px;">
|
||||
<div class="detail-row">
|
||||
<span style="color:#64748b;">Reference</span>
|
||||
<span style="font-weight:700;color:#0f172a;">{{ $invitation->purchaseRequest->request_number }}</span>
|
||||
</div>
|
||||
<div class="detail-row" style="border-top:1px solid #f1f5f9;">
|
||||
<span style="color:#64748b;">Submitted</span>
|
||||
<span style="font-weight:600;color:#0f172a;">{{ now()->format('d M Y, H:i') }}</span>
|
||||
</div>
|
||||
@if($invitation->purchaseRequest->project_name)
|
||||
<div class="detail-row" style="border-top:1px solid #f1f5f9;">
|
||||
<span style="color:#64748b;">Project</span>
|
||||
<span style="font-weight:600;color:#0f172a;">{{ $invitation->purchaseRequest->project_name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p style="font-size:13px;color:#94a3b8;line-height:1.65;">
|
||||
This link has now been closed. No further action is required.<br>
|
||||
We appreciate your time and look forward to working with you.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span style="font-size:11px;color:#cbd5e1;font-weight:500;letter-spacing:.03em;">SteelERP · Procurement Portal</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</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;">
|
||||
<p style="font-size:12px;color:#0369a1;margin:0;line-height:1.7;">
|
||||
<strong>Expected format (2 tabs):</strong><br>
|
||||
<strong>Projects</strong> tab — columns: <em>Company Name</em> | <em>Project Name</em><br>
|
||||
<strong>Departments</strong> tab — columns: <em>Company Name</em> | <em>Department Name</em><br>
|
||||
Companies are created automatically if they don't exist. Duplicates are skipped.
|
||||
<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>
|
||||
↳ Leave Location Name blank for project-only rows. Add multiple rows per project for multiple locations.<br>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,7 @@ use App\Http\Controllers\Sales\SalesOrderController;
|
||||
use App\Http\Controllers\MailAccountController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use App\Http\Controllers\Settings\ProjectSettingController;
|
||||
use App\Http\Controllers\Settings\VatSettingController;
|
||||
use App\Models\Settings\Location;
|
||||
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::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::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
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::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');
|
||||
|
||||
// VAT settings
|
||||
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
|
||||
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user