1972 lines
72 KiB
Markdown
1972 lines
72 KiB
Markdown
# Purchase Pipeline 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:** Replace the disconnected purchase screens with an 8-stage pipeline tracker that shows every purchase like a package delivery — stage, actor, timestamp, and the action needed next.
|
|
|
|
**Architecture:** Add a `stage` enum column to `purchase_requests`, add 3 new tables (`purchase_signatures`, `rfq_invitations`, `supplier_quotes` + items), and wire up 5 new controllers plus 2 services. A `PurchaseStageService` is the single chokepoint for advancing stages. A public `/rfq/{token}` route (outside auth middleware) serves the supplier quote portal.
|
|
|
|
**Tech Stack:** Laravel 12 / PHP 8.2 / SQLite / Tailwind CSS v3 JIT / Alpine.js v3 / HTML5 Canvas (GM signature) / Laravel Mail (RFQ emails) / `wa.me` deep links (WhatsApp)
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### New migrations
|
|
- `database/migrations/2026_05_18_200001_add_stage_to_purchase_requests_table.php`
|
|
- `database/migrations/2026_05_18_200002_add_type_to_grn_items_table.php`
|
|
- `database/migrations/2026_05_18_200003_create_purchase_signatures_table.php`
|
|
- `database/migrations/2026_05_18_200004_create_rfq_invitations_table.php`
|
|
- `database/migrations/2026_05_18_200005_create_supplier_quotes_table.php`
|
|
- `database/migrations/2026_05_18_200006_create_supplier_quote_items_table.php`
|
|
|
|
### New / modified models
|
|
- `app/Models/PurchaseSignature.php` (new)
|
|
- `app/Models/RfqInvitation.php` (new)
|
|
- `app/Models/SupplierQuote.php` (new)
|
|
- `app/Models/SupplierQuoteItem.php` (new)
|
|
- `app/Models/PurchaseRequest.php` (add `stage`, relationships)
|
|
- `app/Models/GrnItem.php` (add `type`)
|
|
|
|
### New services
|
|
- `app/Services/PurchaseStageService.php`
|
|
- `app/Services/RfqInvitationService.php`
|
|
|
|
### New controllers
|
|
- `app/Http/Controllers/Purchase/PurchasePipelineController.php`
|
|
- `app/Http/Controllers/Purchase/PurchaseSignatureController.php`
|
|
- `app/Http/Controllers/Purchase/RfqController.php`
|
|
- `app/Http/Controllers/Purchase/SupplierQuoteController.php`
|
|
- `app/Http/Controllers/Purchase/RfqPortalController.php`
|
|
|
|
### New mail
|
|
- `app/Mail/RfqInvitationMail.php`
|
|
- `resources/views/mail/rfq-invitation.blade.php`
|
|
|
|
### New views
|
|
- `resources/views/purchase/pipeline/index.blade.php`
|
|
- `resources/views/purchase/pipeline/_timeline.blade.php` (partial)
|
|
- `resources/views/purchase/signature/show.blade.php`
|
|
- `resources/views/purchase/rfq/show.blade.php`
|
|
- `resources/views/purchase/quotes/index.blade.php`
|
|
- `resources/views/purchase/quotes/compare.blade.php`
|
|
- `resources/views/rfq/show.blade.php` (public portal)
|
|
- `resources/views/rfq/submitted.blade.php` (thank-you page)
|
|
- `resources/views/rfq/expired.blade.php`
|
|
|
|
### Modified files
|
|
- `routes/web.php` (new routes)
|
|
- `resources/views/layouts/app.blade.php` (sidebar link)
|
|
|
|
---
|
|
|
|
## Task 1: Schema — add `stage` column to `purchase_requests`
|
|
|
|
**Files:**
|
|
- Create: `database/migrations/2026_05_18_200001_add_stage_to_purchase_requests_table.php`
|
|
- Modify: `app/Models/PurchaseRequest.php`
|
|
|
|
- [ ] **Step 1: Write the migration**
|
|
|
|
```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('purchase_requests', function (Blueprint $table) {
|
|
$table->enum('stage', [
|
|
'draft', 'gm_approval', 'rfq', 'quoting',
|
|
'comparison', 'lpo', 'receiving', 'payment', 'complete',
|
|
])->default('draft')->after('status');
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::table('purchase_requests', function (Blueprint $table) {
|
|
$table->dropColumn('stage');
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Update the PurchaseRequest model**
|
|
|
|
Add `stage` to `$fillable` and the relationship stubs that later tasks will fill:
|
|
|
|
```php
|
|
protected $fillable = [
|
|
'request_number', 'date', 'project_name', 'department',
|
|
'requested_by_name', 'required_date_text', 'location',
|
|
'remarks', 'status', 'verified_by_name',
|
|
'requested_by', 'approved_by', 'approved_at', 'stage',
|
|
];
|
|
|
|
public function signature()
|
|
{
|
|
return $this->hasOne(\App\Models\PurchaseSignature::class);
|
|
}
|
|
|
|
public function rfqInvitations()
|
|
{
|
|
return $this->hasMany(\App\Models\RfqInvitation::class);
|
|
}
|
|
|
|
public function supplierQuotes()
|
|
{
|
|
return $this->hasMany(\App\Models\SupplierQuote::class);
|
|
}
|
|
|
|
public function awardedQuote()
|
|
{
|
|
return $this->hasOne(\App\Models\SupplierQuote::class)->where('is_awarded', true);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the migration**
|
|
|
|
```
|
|
php artisan migrate
|
|
```
|
|
|
|
Expected: `Migrated: 2026_05_18_200001_add_stage_to_purchase_requests_table`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add database/migrations/2026_05_18_200001_add_stage_to_purchase_requests_table.php app/Models/PurchaseRequest.php
|
|
git commit -m "feat: add stage column to purchase_requests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Schema — add `type` column to `grn_items`
|
|
|
|
**Files:**
|
|
- Create: `database/migrations/2026_05_18_200002_add_type_to_grn_items_table.php`
|
|
- Modify: `app/Models/GrnItem.php`
|
|
|
|
- [ ] **Step 1: Write the migration**
|
|
|
|
```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('grn_items', function (Blueprint $table) {
|
|
$table->enum('type', ['inventory', 'consumable'])->default('inventory')->after('unit_cost');
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::table('grn_items', function (Blueprint $table) {
|
|
$table->dropColumn('type');
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Update GrnItem model**
|
|
|
|
Add `type` to `$fillable`. Read the current file first, then edit:
|
|
|
|
```php
|
|
// In GrnItem.php, add 'type' to the $fillable array:
|
|
protected $fillable = [
|
|
'goods_receipt_note_id', 'purchase_order_item_id',
|
|
'item_id', 'quantity_received', 'unit_cost', 'type',
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 3: Run the migration**
|
|
|
|
```
|
|
php artisan migrate
|
|
```
|
|
|
|
Expected: `Migrated: 2026_05_18_200002_add_type_to_grn_items_table`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add database/migrations/2026_05_18_200002_add_type_to_grn_items_table.php app/Models/GrnItem.php
|
|
git commit -m "feat: add type (inventory/consumable) to grn_items"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Schema — new tables (`purchase_signatures`, `rfq_invitations`, `supplier_quotes`, `supplier_quote_items`)
|
|
|
|
**Files:**
|
|
- Create: `database/migrations/2026_05_18_200003_create_purchase_signatures_table.php`
|
|
- Create: `database/migrations/2026_05_18_200004_create_rfq_invitations_table.php`
|
|
- Create: `database/migrations/2026_05_18_200005_create_supplier_quotes_table.php`
|
|
- Create: `database/migrations/2026_05_18_200006_create_supplier_quote_items_table.php`
|
|
|
|
- [ ] **Step 1: `purchase_signatures` migration**
|
|
|
|
```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::create('purchase_signatures', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('purchase_request_id')->constrained()->onDelete('cascade');
|
|
$table->foreignId('signed_by')->constrained('users')->onDelete('restrict');
|
|
$table->text('signature_image'); // base64 PNG
|
|
$table->timestamp('signed_at');
|
|
$table->string('ip_address')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::dropIfExists('purchase_signatures');
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: `rfq_invitations` migration**
|
|
|
|
```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::create('rfq_invitations', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('purchase_request_id')->constrained()->onDelete('cascade');
|
|
$table->foreignId('supplier_id')->constrained()->onDelete('restrict');
|
|
$table->string('token', 64)->unique();
|
|
$table->enum('channel', ['email', 'whatsapp', 'both'])->default('both');
|
|
$table->timestamp('sent_at')->nullable();
|
|
$table->timestamp('opened_at')->nullable();
|
|
$table->timestamp('expires_at')->nullable();
|
|
$table->enum('status', ['pending', 'sent', 'opened', 'submitted', 'declined'])->default('pending');
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::dropIfExists('rfq_invitations');
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: `supplier_quotes` migration**
|
|
|
|
```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::create('supplier_quotes', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('rfq_invitation_id')->constrained()->onDelete('cascade');
|
|
$table->foreignId('purchase_request_id')->constrained()->onDelete('cascade');
|
|
$table->foreignId('supplier_id')->constrained()->onDelete('restrict');
|
|
$table->timestamp('submitted_at');
|
|
$table->integer('lead_time_days')->nullable();
|
|
$table->string('payment_terms')->nullable();
|
|
$table->text('notes')->nullable();
|
|
$table->decimal('total_amount', 12, 3)->default(0);
|
|
$table->boolean('is_awarded')->default(false);
|
|
$table->text('award_reason')->nullable();
|
|
$table->timestamp('awarded_at')->nullable();
|
|
$table->foreignId('awarded_by')->nullable()->constrained('users')->nullOnDelete();
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::dropIfExists('supplier_quotes');
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: `supplier_quote_items` migration**
|
|
|
|
```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::create('supplier_quote_items', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('supplier_quote_id')->constrained()->onDelete('cascade');
|
|
$table->text('description');
|
|
$table->string('unit', 50)->nullable();
|
|
$table->decimal('quantity', 10, 3);
|
|
$table->decimal('unit_price', 12, 3);
|
|
$table->decimal('total_price', 12, 3);
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::dropIfExists('supplier_quote_items');
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Run migrations**
|
|
|
|
```
|
|
php artisan migrate
|
|
```
|
|
|
|
Expected: 4 new lines, each `Migrated: 2026_05_18_2000{03,04,05,06}_...`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add database/migrations/2026_05_18_200003_create_purchase_signatures_table.php
|
|
git add database/migrations/2026_05_18_200004_create_rfq_invitations_table.php
|
|
git add database/migrations/2026_05_18_200005_create_supplier_quotes_table.php
|
|
git add database/migrations/2026_05_18_200006_create_supplier_quote_items_table.php
|
|
git commit -m "feat: add pipeline tables (signatures, rfq_invitations, supplier_quotes)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: New models
|
|
|
|
**Files:**
|
|
- Create: `app/Models/PurchaseSignature.php`
|
|
- Create: `app/Models/RfqInvitation.php`
|
|
- Create: `app/Models/SupplierQuote.php`
|
|
- Create: `app/Models/SupplierQuoteItem.php`
|
|
|
|
- [ ] **Step 1: PurchaseSignature model**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class PurchaseSignature extends Model
|
|
{
|
|
protected $fillable = [
|
|
'purchase_request_id', 'signed_by', 'signature_image', 'signed_at', 'ip_address',
|
|
];
|
|
|
|
protected $casts = ['signed_at' => 'datetime'];
|
|
|
|
public function purchaseRequest()
|
|
{
|
|
return $this->belongsTo(PurchaseRequest::class);
|
|
}
|
|
|
|
public function signedBy()
|
|
{
|
|
return $this->belongsTo(User::class, 'signed_by');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: RfqInvitation model**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class RfqInvitation extends Model
|
|
{
|
|
protected $fillable = [
|
|
'purchase_request_id', 'supplier_id', 'token', 'channel',
|
|
'sent_at', 'opened_at', 'expires_at', 'status',
|
|
];
|
|
|
|
protected $casts = [
|
|
'sent_at' => 'datetime',
|
|
'opened_at' => 'datetime',
|
|
'expires_at' => 'datetime',
|
|
];
|
|
|
|
public function purchaseRequest()
|
|
{
|
|
return $this->belongsTo(PurchaseRequest::class);
|
|
}
|
|
|
|
public function supplier()
|
|
{
|
|
return $this->belongsTo(Supplier::class);
|
|
}
|
|
|
|
public function quote()
|
|
{
|
|
return $this->hasOne(SupplierQuote::class);
|
|
}
|
|
|
|
public function isExpired(): bool
|
|
{
|
|
return $this->expires_at && $this->expires_at->isPast();
|
|
}
|
|
|
|
public function isSubmitted(): bool
|
|
{
|
|
return $this->status === 'submitted';
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: SupplierQuote model**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class SupplierQuote extends Model
|
|
{
|
|
protected $fillable = [
|
|
'rfq_invitation_id', 'purchase_request_id', 'supplier_id',
|
|
'submitted_at', 'lead_time_days', 'payment_terms', 'notes',
|
|
'total_amount', 'is_awarded', 'award_reason', 'awarded_at', 'awarded_by',
|
|
];
|
|
|
|
protected $casts = [
|
|
'submitted_at' => 'datetime',
|
|
'awarded_at' => 'datetime',
|
|
'is_awarded' => 'boolean',
|
|
];
|
|
|
|
public function rfqInvitation()
|
|
{
|
|
return $this->belongsTo(RfqInvitation::class);
|
|
}
|
|
|
|
public function purchaseRequest()
|
|
{
|
|
return $this->belongsTo(PurchaseRequest::class);
|
|
}
|
|
|
|
public function supplier()
|
|
{
|
|
return $this->belongsTo(Supplier::class);
|
|
}
|
|
|
|
public function items()
|
|
{
|
|
return $this->hasMany(SupplierQuoteItem::class);
|
|
}
|
|
|
|
public function awardedBy()
|
|
{
|
|
return $this->belongsTo(User::class, 'awarded_by');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: SupplierQuoteItem model**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class SupplierQuoteItem extends Model
|
|
{
|
|
protected $fillable = [
|
|
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price',
|
|
];
|
|
|
|
public function quote()
|
|
{
|
|
return $this->belongsTo(SupplierQuote::class, 'supplier_quote_id');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Models/PurchaseSignature.php app/Models/RfqInvitation.php app/Models/SupplierQuote.php app/Models/SupplierQuoteItem.php
|
|
git commit -m "feat: add pipeline models (PurchaseSignature, RfqInvitation, SupplierQuote, SupplierQuoteItem)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: PurchaseStageService — single place that advances stage
|
|
|
|
**Files:**
|
|
- Create: `app/Services/PurchaseStageService.php`
|
|
|
|
This service is the only place that changes `purchase_requests.stage`. All controllers call it.
|
|
|
|
- [ ] **Step 1: Write the service**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Services;
|
|
|
|
use App\Models\PurchaseRequest;
|
|
|
|
class PurchaseStageService
|
|
{
|
|
const STAGES = [
|
|
'draft', 'gm_approval', 'rfq', 'quoting',
|
|
'comparison', 'lpo', 'receiving', 'payment', 'complete',
|
|
];
|
|
|
|
public function advance(PurchaseRequest $request): void
|
|
{
|
|
$current = array_search($request->stage, self::STAGES);
|
|
if ($current === false || $current === count(self::STAGES) - 1) {
|
|
return;
|
|
}
|
|
$request->update(['stage' => self::STAGES[$current + 1]]);
|
|
}
|
|
|
|
public function setStage(PurchaseRequest $request, string $stage): void
|
|
{
|
|
abort_unless(in_array($stage, self::STAGES), 422, 'Invalid stage');
|
|
$request->update(['stage' => $stage]);
|
|
}
|
|
|
|
public function stageIndex(string $stage): int
|
|
{
|
|
return array_search($stage, self::STAGES) ?: 0;
|
|
}
|
|
|
|
public function stageLabel(string $stage): string
|
|
{
|
|
return match ($stage) {
|
|
'draft' => 'Purchase Request',
|
|
'gm_approval'=> 'GM Signature',
|
|
'rfq' => 'Select Suppliers',
|
|
'quoting' => 'Awaiting Quotes',
|
|
'comparison' => 'Quote Comparison',
|
|
'lpo' => 'LPO Issued',
|
|
'receiving' => 'Receiving Materials',
|
|
'payment' => 'Payment',
|
|
'complete' => 'Complete',
|
|
default => ucfirst($stage),
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add app/Services/PurchaseStageService.php
|
|
git commit -m "feat: add PurchaseStageService for stage advancement"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: GM Signature controller + canvas UI
|
|
|
|
**Files:**
|
|
- Create: `app/Http/Controllers/Purchase/PurchaseSignatureController.php`
|
|
- Create: `resources/views/purchase/signature/show.blade.php`
|
|
|
|
- [ ] **Step 1: Write the controller**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Http\Controllers\Purchase;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\PurchaseRequest;
|
|
use App\Models\PurchaseSignature;
|
|
use App\Services\PurchaseStageService;
|
|
use Illuminate\Http\Request;
|
|
|
|
class PurchaseSignatureController extends Controller
|
|
{
|
|
public function show(PurchaseRequest $request)
|
|
{
|
|
return view('purchase.signature.show', compact('request'));
|
|
}
|
|
|
|
public function store(Request $request, PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
|
{
|
|
$validated = $request->validate([
|
|
'signature_image' => ['required', 'string', 'starts_with:data:image/png;base64,'],
|
|
]);
|
|
|
|
// Prevent double-signing
|
|
if ($purchaseRequest->signature) {
|
|
return back()->with('error', 'This request has already been signed.');
|
|
}
|
|
|
|
PurchaseSignature::create([
|
|
'purchase_request_id' => $purchaseRequest->id,
|
|
'signed_by' => auth()->id(),
|
|
'signature_image' => $validated['signature_image'],
|
|
'signed_at' => now(),
|
|
'ip_address' => $request->ip(),
|
|
]);
|
|
|
|
$stages->advance($purchaseRequest);
|
|
|
|
return redirect()->route('purchase.pipeline.index')
|
|
->with('success', 'Signature saved. Request approved and moved to RFQ stage.');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the signature canvas view**
|
|
|
|
```blade
|
|
@extends('layouts.app')
|
|
|
|
@section('content')
|
|
<div class="max-w-2xl mx-auto py-8 px-4">
|
|
<div style="background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);overflow:hidden;">
|
|
|
|
<!-- Header -->
|
|
<div style="background:linear-gradient(135deg,#7c3aed,#4f46e5);padding:24px 28px;">
|
|
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">GM Digital Signature</div>
|
|
<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;">{{ $request->request_number }}</div>
|
|
<div style="font-size:13px;color:rgba(255,255,255,.8);margin-top:2px;">{{ $request->project_name }}</div>
|
|
</div>
|
|
|
|
<div style="padding:28px;">
|
|
|
|
@if($request->signature)
|
|
<!-- Already signed — show the signature -->
|
|
<div style="text-align:center;margin-bottom:20px;">
|
|
<img src="{{ $request->signature->signature_image }}" style="max-width:100%;border:1px solid #e2e8f0;border-radius:8px;">
|
|
<div style="font-size:12px;color:#64748b;margin-top:8px;">
|
|
Signed by {{ $request->signature->signedBy->name }}
|
|
on {{ $request->signature->signed_at->format('d M Y, H:i') }}
|
|
</div>
|
|
</div>
|
|
@else
|
|
<!-- Signature pad -->
|
|
<p style="font-size:13px;color:#475569;margin-bottom:16px;">Please draw your signature below using your mouse or touch screen.</p>
|
|
|
|
<canvas id="sig-canvas" width="600" height="180"
|
|
style="width:100%;border:2px solid #e2e8f0;border-radius:10px;cursor:crosshair;touch-action:none;"></canvas>
|
|
|
|
<div style="display:flex;gap:10px;margin-top:14px;">
|
|
<button onclick="clearCanvas()" type="button"
|
|
style="flex:1;padding:10px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;font-weight:600;color:#64748b;background:#f8fafc;cursor:pointer;">
|
|
Clear
|
|
</button>
|
|
<button onclick="submitSignature()" type="button"
|
|
style="flex:2;padding:10px;border:none;border-radius:8px;font-size:13px;font-weight:700;color:#fff;background:linear-gradient(135deg,#7c3aed,#4f46e5);cursor:pointer;">
|
|
Confirm Signature
|
|
</button>
|
|
</div>
|
|
|
|
<form id="sig-form" method="POST" action="{{ route('purchase.requests.sign.store', $request) }}">
|
|
@csrf
|
|
<input type="hidden" name="signature_image" id="sig-data">
|
|
</form>
|
|
@endif
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const canvas = document.getElementById('sig-canvas');
|
|
if (canvas) {
|
|
const ctx = canvas.getContext('2d');
|
|
let drawing = false;
|
|
|
|
function getPos(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const src = e.touches ? e.touches[0] : e;
|
|
return { x: (src.clientX - rect.left) * scaleX, y: (src.clientY - rect.top) * scaleY };
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', e => { drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
|
|
canvas.addEventListener('mousemove', e => { if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.stroke(); });
|
|
canvas.addEventListener('mouseup', () => drawing = false);
|
|
canvas.addEventListener('mouseleave', () => drawing = false);
|
|
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); }, { passive: false });
|
|
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.stroke(); }, { passive: false });
|
|
canvas.addEventListener('touchend', () => drawing = false);
|
|
}
|
|
|
|
function clearCanvas() {
|
|
const ctx = document.getElementById('sig-canvas').getContext('2d');
|
|
ctx.clearRect(0, 0, 600, 180);
|
|
}
|
|
|
|
function submitSignature() {
|
|
const canvas = document.getElementById('sig-canvas');
|
|
const data = canvas.toDataURL('image/png');
|
|
// Check if canvas is blank
|
|
const blank = document.createElement('canvas');
|
|
blank.width = canvas.width; blank.height = canvas.height;
|
|
if (data === blank.toDataURL('image/png')) {
|
|
showToast('Please draw your signature first.', 'warn');
|
|
return;
|
|
}
|
|
document.getElementById('sig-data').value = data;
|
|
document.getElementById('sig-form').submit();
|
|
}
|
|
</script>
|
|
@endsection
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/Http/Controllers/Purchase/PurchaseSignatureController.php resources/views/purchase/signature/show.blade.php
|
|
git commit -m "feat: GM signature canvas — controller + view"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: RfqInvitationService + RfqController
|
|
|
|
**Files:**
|
|
- Create: `app/Services/RfqInvitationService.php`
|
|
- Create: `app/Mail/RfqInvitationMail.php`
|
|
- Create: `resources/views/mail/rfq-invitation.blade.php`
|
|
- Create: `app/Http/Controllers/Purchase/RfqController.php`
|
|
- Create: `resources/views/purchase/rfq/show.blade.php`
|
|
|
|
- [ ] **Step 1: Write RfqInvitationService**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Services;
|
|
|
|
use App\Mail\RfqInvitationMail;
|
|
use App\Models\PurchaseRequest;
|
|
use App\Models\RfqInvitation;
|
|
use App\Models\Supplier;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
class RfqInvitationService
|
|
{
|
|
public function invite(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel): RfqInvitation
|
|
{
|
|
$token = bin2hex(random_bytes(32)); // 64 hex chars
|
|
|
|
$invitation = RfqInvitation::create([
|
|
'purchase_request_id' => $purchaseRequest->id,
|
|
'supplier_id' => $supplier->id,
|
|
'token' => $token,
|
|
'channel' => $channel,
|
|
'sent_at' => now(),
|
|
'expires_at' => now()->addDays(7),
|
|
'status' => 'sent',
|
|
]);
|
|
|
|
if (in_array($channel, ['email', 'both']) && $supplier->email) {
|
|
Mail::to($supplier->email)->send(new RfqInvitationMail($invitation));
|
|
}
|
|
|
|
return $invitation;
|
|
}
|
|
|
|
public function whatsappLink(RfqInvitation $invitation): string
|
|
{
|
|
$url = route('rfq.show', $invitation->token);
|
|
$text = "Hello {$invitation->supplier->name},\n\nYou are invited to submit a quote for purchase request {$invitation->purchaseRequest->request_number}.\n\nPlease click the link below to submit your quote:\n{$url}\n\nThis link expires in 7 days and can only be used once.";
|
|
$phone = preg_replace('/\D/', '', $invitation->supplier->phone ?? '');
|
|
return "https://wa.me/{$phone}?text=" . rawurlencode($text);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write RfqInvitationMail**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Mail;
|
|
|
|
use App\Models\RfqInvitation;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Mail\Mailable;
|
|
use Illuminate\Mail\Mailables\Content;
|
|
use Illuminate\Mail\Mailables\Envelope;
|
|
use Illuminate\Queue\SerializesModels;
|
|
|
|
class RfqInvitationMail extends Mailable
|
|
{
|
|
use Queueable, SerializesModels;
|
|
|
|
public function __construct(public RfqInvitation $invitation) {}
|
|
|
|
public function envelope(): Envelope
|
|
{
|
|
return new Envelope(subject: 'Quote Request — ' . $this->invitation->purchaseRequest->request_number);
|
|
}
|
|
|
|
public function content(): Content
|
|
{
|
|
return new Content(view: 'mail.rfq-invitation');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write the email blade template**
|
|
|
|
```blade
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="font-family:Arial,sans-serif;background:#f8fafc;margin:0;padding:20px;">
|
|
<div style="max-width:500px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
|
<div style="font-size:20px;font-weight:700;color:#1e293b;margin-bottom:4px;">Quote Request</div>
|
|
<div style="font-size:13px;color:#64748b;margin-bottom:24px;">{{ $invitation->purchaseRequest->request_number }}</div>
|
|
|
|
<p style="font-size:14px;color:#334155;margin-bottom:16px;">Dear {{ $invitation->supplier->name }},</p>
|
|
<p style="font-size:14px;color:#334155;margin-bottom:24px;">
|
|
You have been invited to submit a price quotation for a purchase request. Please click the button below to view the required items and submit your quote.
|
|
</p>
|
|
|
|
<a href="{{ route('rfq.show', $invitation->token) }}"
|
|
style="display:inline-block;background:#2563eb;color:#fff;padding:12px 28px;border-radius:8px;font-size:14px;font-weight:700;text-decoration:none;margin-bottom:24px;">
|
|
Submit Quote →
|
|
</a>
|
|
|
|
<p style="font-size:12px;color:#94a3b8;">This link expires on {{ $invitation->expires_at->format('d M Y') }} and can only be submitted once.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 4: Write RfqController**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Http\Controllers\Purchase;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\PurchaseRequest;
|
|
use App\Models\Supplier;
|
|
use App\Services\PurchaseStageService;
|
|
use App\Services\RfqInvitationService;
|
|
use Illuminate\Http\Request;
|
|
|
|
class RfqController extends Controller
|
|
{
|
|
public function show(PurchaseRequest $request)
|
|
{
|
|
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
|
$invitations = $request->rfqInvitations()->with('supplier', 'quote')->get();
|
|
return view('purchase.rfq.show', compact('request', 'suppliers', 'invitations'));
|
|
}
|
|
|
|
public function store(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
|
{
|
|
$validated = $request->validate([
|
|
'suppliers' => ['required', 'array', 'min:1'],
|
|
'suppliers.*.id' => ['required', 'exists:suppliers,id'],
|
|
'suppliers.*.channel' => ['required', 'in:email,whatsapp,both'],
|
|
]);
|
|
|
|
$alreadyInvited = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
|
|
|
foreach ($validated['suppliers'] as $entry) {
|
|
if (in_array($entry['id'], $alreadyInvited)) {
|
|
continue; // skip already-invited suppliers
|
|
}
|
|
$supplier = Supplier::findOrFail($entry['id']);
|
|
$service->invite($purchaseRequest, $supplier, $entry['channel']);
|
|
}
|
|
|
|
$stages->setStage($purchaseRequest, 'quoting');
|
|
|
|
return redirect()->route('purchase.requests.rfq', $purchaseRequest)
|
|
->with('success', 'Invitations sent. Waiting for supplier quotes.');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Write the RFQ selection view**
|
|
|
|
```blade
|
|
@extends('layouts.app')
|
|
|
|
@section('content')
|
|
<div class="max-w-3xl mx-auto py-8 px-4">
|
|
<div style="background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);overflow:hidden;">
|
|
|
|
<div style="background:linear-gradient(135deg,#0ea5e9,#0284c7);padding:24px 28px;">
|
|
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">RFQ — Select Suppliers</div>
|
|
<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;">{{ $request->request_number }}</div>
|
|
</div>
|
|
|
|
<div style="padding:28px;">
|
|
|
|
@if($invitations->count())
|
|
<div style="margin-bottom:24px;">
|
|
<div style="font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;margin-bottom:10px;">Already Invited</div>
|
|
<div style="display:flex;flex-direction:column;gap:8px;">
|
|
@foreach($invitations as $inv)
|
|
<div style="display:flex;align-items:center;justify-content:space-between;background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:10px 14px;">
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:#0f172a;">{{ $inv->supplier->name }}</div>
|
|
<div style="font-size:11px;color:#64748b;">{{ ucfirst($inv->channel) }} · {{ $inv->sent_at?->format('d M Y') }}</div>
|
|
</div>
|
|
<div style="font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;
|
|
background:{{ $inv->status === 'submitted' ? '#dcfce7' : '#fef9c3' }};
|
|
color:{{ $inv->status === 'submitted' ? '#15803d' : '#92400e' }};">
|
|
{{ ucfirst($inv->status) }}
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<form method="POST" action="{{ route('purchase.requests.rfq.store', $request) }}" id="rfq-form">
|
|
@csrf
|
|
<div style="font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;margin-bottom:10px;">Add Suppliers</div>
|
|
|
|
<input type="text" placeholder="Search suppliers..." oninput="filterSuppliers(this.value)"
|
|
style="width:100%;padding:9px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;margin-bottom:12px;box-sizing:border-box;">
|
|
|
|
<div id="supplier-list" style="display:flex;flex-direction:column;gap:6px;max-height:360px;overflow-y:auto;">
|
|
@foreach($suppliers as $supplier)
|
|
@php $alreadyInvited = $invitations->where('supplier_id', $supplier->id)->first(); @endphp
|
|
<div class="sup-row" data-name="{{ strtolower($supplier->name) }}"
|
|
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border:1px solid #e2e8f0;border-radius:8px;{{ $alreadyInvited ? 'opacity:.5;pointer-events:none;' : '' }}">
|
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;flex:1;">
|
|
<input type="checkbox" name="suppliers[]" value="{{ $supplier->id }}" data-idx="{{ $supplier->id }}"
|
|
onchange="toggleSupplier(this)"
|
|
style="width:16px;height:16px;cursor:pointer;" {{ $alreadyInvited ? 'disabled' : '' }}>
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:#0f172a;">{{ $supplier->name }}</div>
|
|
<div style="font-size:11px;color:#64748b;">{{ $supplier->email ?? '—' }} · {{ $supplier->phone ?? '—' }}</div>
|
|
</div>
|
|
</label>
|
|
<div id="channel-{{ $supplier->id }}" style="display:none;gap:6px;">
|
|
<input type="hidden" name="suppliers[{{ $supplier->id }}][id]" value="{{ $supplier->id }}">
|
|
<label style="font-size:11px;font-weight:600;cursor:pointer;">
|
|
<input type="radio" name="suppliers[{{ $supplier->id }}][channel]" value="whatsapp"> WhatsApp
|
|
</label>
|
|
<label style="font-size:11px;font-weight:600;cursor:pointer;">
|
|
<input type="radio" name="suppliers[{{ $supplier->id }}][channel]" value="email"> Email
|
|
</label>
|
|
<label style="font-size:11px;font-weight:600;cursor:pointer;">
|
|
<input type="radio" name="suppliers[{{ $supplier->id }}][channel]" value="both" checked> Both
|
|
</label>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
<button type="submit" style="width:100%;margin-top:20px;padding:12px;background:#0ea5e9;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;">
|
|
Send Invitations
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function toggleSupplier(cb) {
|
|
const channelDiv = document.getElementById('channel-' + cb.dataset.idx);
|
|
channelDiv.style.display = cb.checked ? 'flex' : 'none';
|
|
}
|
|
function filterSuppliers(q) {
|
|
document.querySelectorAll('.sup-row').forEach(row => {
|
|
row.style.display = row.dataset.name.includes(q.toLowerCase()) ? '' : 'none';
|
|
});
|
|
}
|
|
</script>
|
|
@endsection
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add app/Services/RfqInvitationService.php app/Mail/RfqInvitationMail.php resources/views/mail/rfq-invitation.blade.php app/Http/Controllers/Purchase/RfqController.php resources/views/purchase/rfq/show.blade.php
|
|
git commit -m "feat: RFQ invitation service, mail, controller and view"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Public supplier quote portal (RfqPortalController + views)
|
|
|
|
**Files:**
|
|
- Create: `app/Http/Controllers/Purchase/RfqPortalController.php`
|
|
- Create: `resources/views/rfq/show.blade.php`
|
|
- Create: `resources/views/rfq/submitted.blade.php`
|
|
- Create: `resources/views/rfq/expired.blade.php`
|
|
|
|
- [ ] **Step 1: Write RfqPortalController**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Http\Controllers\Purchase;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\RfqInvitation;
|
|
use App\Models\SupplierQuote;
|
|
use App\Models\SupplierQuoteItem;
|
|
use App\Services\PurchaseStageService;
|
|
use Illuminate\Http\Request;
|
|
|
|
class RfqPortalController extends Controller
|
|
{
|
|
private function resolveInvitation(string $token): RfqInvitation
|
|
{
|
|
$invitation = RfqInvitation::where('token', $token)
|
|
->with(['purchaseRequest.items', 'supplier'])
|
|
->firstOrFail();
|
|
return $invitation;
|
|
}
|
|
|
|
public function show(string $token)
|
|
{
|
|
$invitation = $this->resolveInvitation($token);
|
|
|
|
if ($invitation->isSubmitted()) {
|
|
return view('rfq.submitted', compact('invitation'));
|
|
}
|
|
|
|
if ($invitation->isExpired()) {
|
|
return view('rfq.expired', compact('invitation'));
|
|
}
|
|
|
|
// Mark as opened on first visit
|
|
if ($invitation->status === 'sent') {
|
|
$invitation->update(['status' => 'opened', 'opened_at' => now()]);
|
|
}
|
|
|
|
$purchaseRequest = $invitation->purchaseRequest;
|
|
$items = $purchaseRequest->items;
|
|
|
|
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
|
|
}
|
|
|
|
public function submit(Request $request, string $token, PurchaseStageService $stages)
|
|
{
|
|
$invitation = $this->resolveInvitation($token);
|
|
|
|
if ($invitation->isSubmitted() || $invitation->isExpired()) {
|
|
abort(403);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'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'],
|
|
]);
|
|
|
|
$purchaseItems = $invitation->purchaseRequest->items;
|
|
|
|
$quote = SupplierQuote::create([
|
|
'rfq_invitation_id' => $invitation->id,
|
|
'purchase_request_id'=> $invitation->purchase_request_id,
|
|
'supplier_id' => $invitation->supplier_id,
|
|
'submitted_at' => now(),
|
|
'lead_time_days' => $validated['lead_time_days'],
|
|
'payment_terms' => $validated['payment_terms'],
|
|
'notes' => $validated['notes'],
|
|
'total_amount' => 0,
|
|
]);
|
|
|
|
$total = 0;
|
|
foreach ($purchaseItems as $i => $item) {
|
|
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
|
$totalPrice = $unitPrice * $item->quantity;
|
|
$total += $totalPrice;
|
|
|
|
SupplierQuoteItem::create([
|
|
'supplier_quote_id' => $quote->id,
|
|
'description' => $item->description,
|
|
'unit' => $item->unit ?? '',
|
|
'quantity' => $item->quantity,
|
|
'unit_price' => $unitPrice,
|
|
'total_price' => $totalPrice,
|
|
]);
|
|
}
|
|
|
|
$quote->update(['total_amount' => $total]);
|
|
$invitation->update(['status' => 'submitted']);
|
|
|
|
return view('rfq.submitted', compact('invitation'));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Public quote form view (`resources/views/rfq/show.blade.php`)**
|
|
|
|
Note: This view uses no auth layout — it is a standalone public page.
|
|
|
|
```blade
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<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; }
|
|
.card { background:#fff; border-radius:16px; box-shadow:0 4px 24px rgba(0,0,0,.08); max-width:700px; margin:0 auto; overflow:hidden; }
|
|
.header { background:linear-gradient(135deg,#2563eb,#1d4ed8); padding:28px 32px; }
|
|
.body { padding:32px; }
|
|
label { display:block; font-size:12px; 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:1px solid #e2e8f0; border-radius:8px; font-size:13px; }
|
|
input:focus, textarea:focus { outline:none; border-color:#2563eb; }
|
|
.btn { width:100%; padding:14px; background:#2563eb; color:#fff; border:none; border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; margin-top:24px; }
|
|
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
th { background:#f8fafc; padding:10px 12px; text-align:left; font-size:11px; font-weight:700; color:#64748b; text-transform:uppercase; }
|
|
td { padding:10px 12px; border-bottom:1px solid #f1f5f9; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="header">
|
|
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Quote Request</div>
|
|
<div style="font-size:22px;font-weight:700;color:#fff;margin-top:4px;">{{ $purchaseRequest->request_number }}</div>
|
|
<div style="font-size:13px;color:rgba(255,255,255,.8);margin-top:2px;">{{ $purchaseRequest->project_name }}</div>
|
|
</div>
|
|
|
|
<div class="body">
|
|
<p style="font-size:14px;color:#334155;margin-bottom:24px;">
|
|
Hello <strong>{{ $invitation->supplier->name }}</strong>, please fill in your prices for the items below and submit your quote. This link can only be submitted once.
|
|
</p>
|
|
|
|
<form method="POST" action="{{ route('rfq.submit', $invitation->token) }}">
|
|
@csrf
|
|
|
|
<!-- Items table -->
|
|
<div style="margin-bottom:24px;overflow-x:auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Description</th>
|
|
<th>Qty</th>
|
|
<th>Unit</th>
|
|
<th>Unit Price (BD)</th>
|
|
<th>Total (BD)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($items as $i => $item)
|
|
<tr>
|
|
<td>{{ $i + 1 }}</td>
|
|
<td>{{ $item->description }}</td>
|
|
<td>{{ $item->quantity }}</td>
|
|
<td>{{ $item->unit ?? '—' }}</td>
|
|
<td>
|
|
<input type="number" name="items[{{ $i }}][unit_price]" min="0" step="0.001" required
|
|
oninput="calcRow({{ $i }}, {{ $item->quantity }})"
|
|
id="up-{{ $i }}" style="width:110px;">
|
|
</td>
|
|
<td id="tot-{{ $i }}" style="font-weight:600;">—</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td colspan="5" style="text-align:right;font-weight:700;font-size:13px;padding:12px;">Grand Total:</td>
|
|
<td style="font-weight:700;font-size:14px;color:#2563eb;" 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;">
|
|
<div>
|
|
<label>Lead Time (days)</label>
|
|
<input type="number" name="lead_time_days" min="0">
|
|
</div>
|
|
<div>
|
|
<label>Payment Terms</label>
|
|
<input type="text" name="payment_terms" placeholder="e.g. 30 days net">
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom:8px;">
|
|
<label>Notes</label>
|
|
<textarea name="notes" rows="3" placeholder="Any additional notes..."></textarea>
|
|
</div>
|
|
|
|
<button class="btn">Submit My Quote →</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const totals = {};
|
|
function calcRow(i, qty) {
|
|
const up = parseFloat(document.getElementById('up-' + i).value) || 0;
|
|
const tot = up * qty;
|
|
totals[i] = tot;
|
|
document.getElementById('tot-' + i).textContent = 'BD ' + tot.toFixed(3);
|
|
const grand = Object.values(totals).reduce((a,b) => a+b, 0);
|
|
document.getElementById('grand-total').textContent = 'BD ' + grand.toFixed(3);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 3: Thank-you view (`resources/views/rfq/submitted.blade.php`)**
|
|
|
|
```blade
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Quote Submitted</title>
|
|
<style>
|
|
* { box-sizing:border-box; margin:0; padding:0; }
|
|
body { font-family: system-ui, sans-serif; background:#f0fdf4; min-height:100vh; display:flex; align-items:center; justify-content:center; }
|
|
.card { background:#fff; border-radius:16px; box-shadow:0 4px 24px rgba(0,0,0,.08); padding:48px 40px; max-width:480px; text-align:center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div style="font-size:56px;margin-bottom:16px;">✅</div>
|
|
<div style="font-size:22px;font-weight:700;color:#15803d;margin-bottom:8px;">Quote Submitted</div>
|
|
<div style="font-size:14px;color:#475569;">Thank you, {{ $invitation->supplier->name }}. Your quote for <strong>{{ $invitation->purchaseRequest->request_number }}</strong> has been received. You will be contacted if you are selected.</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 4: Expired view (`resources/views/rfq/expired.blade.php`)**
|
|
|
|
```blade
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Link Expired</title>
|
|
<style>
|
|
* { box-sizing:border-box; margin:0; padding:0; }
|
|
body { font-family: system-ui, sans-serif; background:#fef2f2; min-height:100vh; display:flex; align-items:center; justify-content:center; }
|
|
.card { background:#fff; border-radius:16px; box-shadow:0 4px 24px rgba(0,0,0,.08); padding:48px 40px; max-width:480px; text-align:center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div style="font-size:56px;margin-bottom:16px;">⏰</div>
|
|
<div style="font-size:22px;font-weight:700;color:#dc2626;margin-bottom:8px;">Link Expired</div>
|
|
<div style="font-size:14px;color:#475569;">This quote invitation link has expired. Please contact the purchasing team if you still wish to submit a quote.</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Http/Controllers/Purchase/RfqPortalController.php resources/views/rfq/
|
|
git commit -m "feat: public supplier quote portal — form, submitted, expired views"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: SupplierQuoteController — view quotes + compare + award
|
|
|
|
**Files:**
|
|
- Create: `app/Http/Controllers/Purchase/SupplierQuoteController.php`
|
|
- Create: `resources/views/purchase/quotes/index.blade.php`
|
|
- Create: `resources/views/purchase/quotes/compare.blade.php`
|
|
|
|
- [ ] **Step 1: Write SupplierQuoteController**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Http\Controllers\Purchase;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\PurchaseRequest;
|
|
use App\Models\SupplierQuote;
|
|
use App\Services\PurchaseStageService;
|
|
use Illuminate\Http\Request;
|
|
|
|
class SupplierQuoteController extends Controller
|
|
{
|
|
public function index(PurchaseRequest $request)
|
|
{
|
|
$quotes = $request->supplierQuotes()->with('supplier', 'items')->get();
|
|
return view('purchase.quotes.index', compact('request', 'quotes'));
|
|
}
|
|
|
|
public function compare(PurchaseRequest $request)
|
|
{
|
|
$quotes = $request->supplierQuotes()->with('supplier', 'items')->get();
|
|
$items = $request->items;
|
|
return view('purchase.quotes.compare', compact('request', 'quotes', 'items'));
|
|
}
|
|
|
|
public function award(Request $request, PurchaseRequest $purchaseRequest, SupplierQuote $quote, PurchaseStageService $stages)
|
|
{
|
|
$validated = $request->validate([
|
|
'award_reason' => ['required', 'string', 'min:5'],
|
|
]);
|
|
|
|
if ($purchaseRequest->awardedQuote) {
|
|
return back()->with('error', 'A quote has already been awarded for this request.');
|
|
}
|
|
|
|
// Mark all other quotes as not awarded (they remain in the table)
|
|
$purchaseRequest->supplierQuotes()->where('id', '!=', $quote->id)->update(['is_awarded' => false]);
|
|
|
|
$quote->update([
|
|
'is_awarded' => true,
|
|
'award_reason' => $validated['award_reason'],
|
|
'awarded_at' => now(),
|
|
'awarded_by' => auth()->id(),
|
|
]);
|
|
|
|
$stages->setStage($purchaseRequest, 'lpo');
|
|
|
|
return redirect()->route('purchase.pipeline.index')
|
|
->with('success', "Quote from {$quote->supplier->name} awarded. Ready to issue LPO.");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write quotes index view**
|
|
|
|
```blade
|
|
@extends('layouts.app')
|
|
|
|
@section('content')
|
|
<div class="max-w-4xl mx-auto py-8 px-4">
|
|
<div style="background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);overflow:hidden;">
|
|
|
|
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);padding:24px 28px;display:flex;align-items:center;justify-content:space-between;">
|
|
<div>
|
|
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Supplier Quotes</div>
|
|
<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;">{{ $request->request_number }}</div>
|
|
</div>
|
|
@if($quotes->count() >= 1)
|
|
<a href="{{ route('purchase.requests.compare', $request) }}"
|
|
style="padding:10px 20px;background:#fff;color:#d97706;border-radius:8px;font-size:13px;font-weight:700;text-decoration:none;">
|
|
Compare →
|
|
</a>
|
|
@endif
|
|
</div>
|
|
|
|
<div style="padding:24px;">
|
|
@if($quotes->isEmpty())
|
|
<p style="text-align:center;color:#94a3b8;padding:40px 0;">No quotes received yet.</p>
|
|
@else
|
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
|
@foreach($quotes as $quote)
|
|
<div style="border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : '#e2e8f0' }};border-radius:12px;padding:16px 20px;background:{{ $quote->is_awarded ? '#f0fdf4' : '#fff' }};">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
|
<div style="font-size:14px;font-weight:700;color:#0f172a;">{{ $quote->supplier->name }}</div>
|
|
<div style="font-size:16px;font-weight:700;color:#2563eb;">BD {{ number_format($quote->total_amount, 3) }}</div>
|
|
</div>
|
|
<div style="font-size:12px;color:#64748b;">
|
|
Lead time: {{ $quote->lead_time_days ? $quote->lead_time_days.' days' : '—' }} ·
|
|
Terms: {{ $quote->payment_terms ?: '—' }} ·
|
|
Submitted: {{ $quote->submitted_at->format('d M Y') }}
|
|
</div>
|
|
@if($quote->is_awarded)
|
|
<div style="margin-top:8px;font-size:11px;font-weight:700;color:#15803d;">✓ Awarded</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
```
|
|
|
|
- [ ] **Step 3: Write quote comparison view**
|
|
|
|
```blade
|
|
@extends('layouts.app')
|
|
|
|
@section('content')
|
|
<div class="max-w-6xl mx-auto py-8 px-4">
|
|
<div style="background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);overflow:hidden;">
|
|
|
|
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);padding:24px 28px;">
|
|
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;">Quote Comparison</div>
|
|
<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;">{{ $request->request_number }}</div>
|
|
</div>
|
|
|
|
<div style="overflow-x:auto;padding:24px;">
|
|
@if($quotes->isEmpty())
|
|
<p style="text-align:center;color:#94a3b8;padding:40px 0;">No quotes to compare.</p>
|
|
@else
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
|
<thead>
|
|
<tr>
|
|
<th style="padding:10px 12px;text-align:left;background:#f8fafc;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;min-width:180px;">Item</th>
|
|
@foreach($quotes as $quote)
|
|
<th style="padding:10px 12px;text-align:center;background:#f8fafc;font-size:12px;font-weight:700;color:#0f172a;min-width:140px;">
|
|
{{ $quote->supplier->name }}<br>
|
|
<span style="font-size:10px;font-weight:400;color:#64748b;">{{ $quote->submitted_at->format('d M') }}</span>
|
|
</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{-- Items rows --}}
|
|
@foreach($items as $i => $reqItem)
|
|
<tr>
|
|
<td style="padding:10px 12px;border-bottom:1px solid #f1f5f9;font-weight:500;">
|
|
{{ $reqItem->description }}<br>
|
|
<span style="font-size:11px;color:#94a3b8;">Qty: {{ $reqItem->quantity }}</span>
|
|
</td>
|
|
@php
|
|
$rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price ?? null)->filter();
|
|
$minPrice = $rowPrices->min();
|
|
@endphp
|
|
@foreach($quotes as $q)
|
|
@php $qItem = $q->items->get($i); @endphp
|
|
<td style="padding:10px 12px;border-bottom:1px solid #f1f5f9;text-align:center;
|
|
background:{{ $qItem && $qItem->unit_price == $minPrice ? '#f0fdf4' : '' }};">
|
|
@if($qItem)
|
|
<div style="font-weight:600;color:{{ $qItem->unit_price == $minPrice ? '#15803d' : '#0f172a' }};">
|
|
BD {{ number_format($qItem->unit_price, 3) }}
|
|
</div>
|
|
<div style="font-size:11px;color:#64748b;">Total: BD {{ number_format($qItem->total_price, 3) }}</div>
|
|
@else
|
|
<span style="color:#cbd5e1;">—</span>
|
|
@endif
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
@endforeach
|
|
|
|
{{-- Totals row --}}
|
|
<tr style="background:#f8fafc;">
|
|
<td style="padding:12px;font-weight:700;">Grand Total</td>
|
|
@php $minTotal = $quotes->min('total_amount'); @endphp
|
|
@foreach($quotes as $quote)
|
|
<td style="padding:12px;text-align:center;font-weight:700;font-size:14px;
|
|
color:{{ $quote->total_amount == $minTotal ? '#15803d' : '#0f172a' }};">
|
|
BD {{ number_format($quote->total_amount, 3) }}
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
|
|
{{-- Lead time --}}
|
|
<tr>
|
|
<td style="padding:10px 12px;color:#64748b;font-size:12px;">Lead Time</td>
|
|
@foreach($quotes as $quote)
|
|
<td style="padding:10px 12px;text-align:center;font-size:12px;color:#64748b;">
|
|
{{ $quote->lead_time_days ? $quote->lead_time_days.' days' : '—' }}
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
|
|
{{-- Payment terms --}}
|
|
<tr>
|
|
<td style="padding:10px 12px;color:#64748b;font-size:12px;">Payment Terms</td>
|
|
@foreach($quotes as $quote)
|
|
<td style="padding:10px 12px;text-align:center;font-size:12px;color:#64748b;">
|
|
{{ $quote->payment_terms ?: '—' }}
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
|
|
{{-- Award buttons --}}
|
|
@if(!$request->awardedQuote)
|
|
<tr style="background:#fffbeb;">
|
|
<td style="padding:12px;font-weight:700;font-size:12px;color:#92400e;">Award to:</td>
|
|
@foreach($quotes as $quote)
|
|
<td style="padding:12px;text-align:center;">
|
|
<button onclick="openAwardModal({{ $quote->id }}, '{{ addslashes($quote->supplier->name) }}')"
|
|
style="padding:8px 14px;background:#f59e0b;color:#fff;border:none;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;">
|
|
Award →
|
|
</button>
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
@else
|
|
<tr style="background:#f0fdf4;">
|
|
<td colspan="{{ $quotes->count() + 1 }}" style="padding:12px;text-align:center;font-size:13px;font-weight:700;color:#15803d;">
|
|
✓ Quote awarded to {{ $request->awardedQuote->supplier->name }}
|
|
</td>
|
|
</tr>
|
|
@endif
|
|
</tbody>
|
|
</table>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Award modal -->
|
|
<div id="award-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;align-items:center;justify-content:center;">
|
|
<div style="background:#fff;border-radius:16px;padding:32px;max-width:440px;width:90%;">
|
|
<div style="font-size:16px;font-weight:700;color:#0f172a;margin-bottom:4px;">Award Quote</div>
|
|
<div style="font-size:13px;color:#64748b;margin-bottom:20px;" id="award-supplier-name"></div>
|
|
<form id="award-form" method="POST">
|
|
@csrf
|
|
<label style="display:block;font-size:12px;font-weight:700;color:#64748b;margin-bottom:6px;">Reason for selection</label>
|
|
<textarea name="award_reason" rows="3" required style="width:100%;padding:9px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;margin-bottom:16px;"></textarea>
|
|
<div style="display:flex;gap:10px;">
|
|
<button type="button" onclick="closeAwardModal()" style="flex:1;padding:10px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;font-weight:600;background:#f8fafc;cursor:pointer;">Cancel</button>
|
|
<button type="submit" style="flex:2;padding:10px;background:#f59e0b;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">Confirm Award</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function openAwardModal(quoteId, supplierName) {
|
|
document.getElementById('award-form').action = '/purchase/requests/{{ $request->id }}/quotes/' + quoteId + '/award';
|
|
document.getElementById('award-supplier-name').textContent = 'Awarding to: ' + supplierName;
|
|
document.getElementById('award-modal').style.display = 'flex';
|
|
}
|
|
function closeAwardModal() {
|
|
document.getElementById('award-modal').style.display = 'none';
|
|
}
|
|
</script>
|
|
@endsection
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/Http/Controllers/Purchase/SupplierQuoteController.php resources/views/purchase/quotes/
|
|
git commit -m "feat: supplier quote controller — index, compare, award views"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Pipeline index view with vertical timeline
|
|
|
|
**Files:**
|
|
- Create: `app/Http/Controllers/Purchase/PurchasePipelineController.php`
|
|
- Create: `resources/views/purchase/pipeline/index.blade.php`
|
|
|
|
- [ ] **Step 1: Write PurchasePipelineController**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Http\Controllers\Purchase;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\PurchaseRequest;
|
|
use App\Services\PurchaseStageService;
|
|
|
|
class PurchasePipelineController extends Controller
|
|
{
|
|
public function index(PurchaseStageService $stages)
|
|
{
|
|
$requests = PurchaseRequest::with([
|
|
'requestedBy', 'signature', 'rfqInvitations', 'supplierQuotes', 'awardedQuote.supplier',
|
|
])->orderByRaw("CASE stage
|
|
WHEN 'draft' THEN 0 WHEN 'gm_approval' THEN 1 WHEN 'rfq' THEN 2
|
|
WHEN 'quoting' THEN 3 WHEN 'comparison' THEN 4 WHEN 'lpo' THEN 5
|
|
WHEN 'receiving' THEN 6 WHEN 'payment' THEN 7 WHEN 'complete' THEN 8
|
|
ELSE 9 END")
|
|
->latest()->get();
|
|
|
|
return view('purchase.pipeline.index', compact('requests', 'stages'));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the pipeline index view**
|
|
|
|
```blade
|
|
@extends('layouts.app')
|
|
|
|
@section('content')
|
|
<div class="max-w-5xl mx-auto py-8 px-4">
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;">
|
|
<div>
|
|
<h1 style="font-size:22px;font-weight:700;color:#0f172a;">Purchase Pipeline</h1>
|
|
<p style="font-size:13px;color:#64748b;margin-top:2px;">Track every purchase from request to payment</p>
|
|
</div>
|
|
<a href="{{ route('purchase.requests.create') }}"
|
|
style="padding:10px 20px;background:#2563eb;color:#fff;border-radius:8px;font-size:13px;font-weight:700;text-decoration:none;">
|
|
+ New Request
|
|
</a>
|
|
</div>
|
|
|
|
@if($requests->isEmpty())
|
|
<div style="text-align:center;padding:80px 0;color:#94a3b8;">
|
|
<div style="font-size:48px;margin-bottom:12px;">📋</div>
|
|
<div style="font-size:16px;font-weight:600;">No purchases yet</div>
|
|
<div style="font-size:13px;margin-top:4px;">Create a purchase request to get started.</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
|
@foreach($requests as $pr)
|
|
@php
|
|
$stageIdx = $stages->stageIndex($pr->stage);
|
|
$allStages = \App\Services\PurchaseStageService::STAGES;
|
|
@endphp
|
|
<div style="background:#fff;border-radius:16px;box-shadow:0 2px 12px rgba(0,0,0,.06);overflow:hidden;">
|
|
|
|
<!-- Card header -->
|
|
<div style="padding:20px 24px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;">
|
|
<div>
|
|
<div style="font-size:15px;font-weight:700;color:#0f172a;">{{ $pr->request_number }}</div>
|
|
<div style="font-size:12px;color:#64748b;margin-top:2px;">{{ $pr->project_name }} · {{ $pr->date->format('d M Y') }}</div>
|
|
</div>
|
|
<div style="font-size:11px;font-weight:700;padding:4px 12px;border-radius:20px;
|
|
background:{{ $pr->stage === 'complete' ? '#dcfce7' : '#fffbeb' }};
|
|
color:{{ $pr->stage === 'complete' ? '#15803d' : '#92400e' }};">
|
|
{{ $stages->stageLabel($pr->stage) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mini progress bar -->
|
|
<div style="height:4px;background:#f1f5f9;">
|
|
<div style="height:4px;background:{{ $pr->stage === 'complete' ? '#22c55e' : '#f59e0b' }};
|
|
width:{{ round(($stageIdx / (count($allStages) - 1)) * 100) }}%;
|
|
transition:width .4s ease;"></div>
|
|
</div>
|
|
|
|
<!-- Vertical timeline (compact) -->
|
|
<div style="padding:20px 24px;">
|
|
<div style="display:flex;flex-direction:column;gap:0;">
|
|
|
|
@foreach($allStages as $i => $stage)
|
|
@php
|
|
$done = $i < $stageIdx;
|
|
$current = $i === $stageIdx;
|
|
$future = $i > $stageIdx;
|
|
@endphp
|
|
<div style="display:flex;align-items:stretch;gap:14px;">
|
|
<!-- dot + line -->
|
|
<div style="display:flex;flex-direction:column;align-items:center;width:20px;flex-shrink:0;">
|
|
<div style="width:16px;height:16px;border-radius:50%;flex-shrink:0;margin-top:2px;
|
|
background:{{ $done ? '#2563eb' : ($current ? '#f59e0b' : '#e2e8f0') }};
|
|
{{ $current ? 'box-shadow:0 0 0 3px #fde68a;' : '' }}
|
|
display:flex;align-items:center;justify-content:center;">
|
|
@if($done)
|
|
<svg width="8" height="8" viewBox="0 0 8 8"><path d="M1 4l2 2 4-4" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>
|
|
@endif
|
|
</div>
|
|
@if($i < count($allStages) - 1)
|
|
<div style="width:2px;flex:1;min-height:8px;background:{{ $done ? '#2563eb' : '#e2e8f0' }};margin:2px 0;"></div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- content -->
|
|
<div style="padding-bottom:{{ $i < count($allStages) - 1 ? '10' : '0' }}px;flex:1;min-width:0;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px;">
|
|
<span style="font-size:13px;font-weight:{{ $current ? '700' : '500' }};
|
|
color:{{ $done ? '#2563eb' : ($current ? '#d97706' : '#94a3b8') }};">
|
|
{{ $stages->stageLabel($stage) }}
|
|
</span>
|
|
|
|
{{-- Action button for current stage --}}
|
|
@if($current)
|
|
@if($stage === 'gm_approval')
|
|
<a href="{{ route('purchase.requests.sign', $pr) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#7c3aed;color:#fff;border-radius:6px;text-decoration:none;">
|
|
Sign →
|
|
</a>
|
|
@elseif($stage === 'rfq')
|
|
<a href="{{ route('purchase.requests.rfq', $pr) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#0ea5e9;color:#fff;border-radius:6px;text-decoration:none;">
|
|
Select Suppliers →
|
|
</a>
|
|
@elseif($stage === 'quoting')
|
|
<a href="{{ route('purchase.requests.quotes', $pr) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#f59e0b;color:#fff;border-radius:6px;text-decoration:none;">
|
|
View Quotes ({{ $pr->supplierQuotes->count() }}) →
|
|
</a>
|
|
@elseif($stage === 'comparison')
|
|
<a href="{{ route('purchase.requests.compare', $pr) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#f59e0b;color:#fff;border-radius:6px;text-decoration:none;">
|
|
Compare & Award →
|
|
</a>
|
|
@elseif($stage === 'lpo')
|
|
<a href="{{ route('purchase.orders.create', ['request_id' => $pr->id]) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#16a34a;color:#fff;border-radius:6px;text-decoration:none;">
|
|
Issue LPO →
|
|
</a>
|
|
@elseif($stage === 'receiving')
|
|
<a href="{{ route('purchase.grns.create', ['request_id' => $pr->id]) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#16a34a;color:#fff;border-radius:6px;text-decoration:none;">
|
|
Record GRN →
|
|
</a>
|
|
@elseif($stage === 'payment')
|
|
<a href="{{ route('purchase.payments.create', ['request_id' => $pr->id]) }}"
|
|
style="font-size:11px;font-weight:700;padding:3px 10px;background:#0f172a;color:#fff;border-radius:6px;text-decoration:none;">
|
|
Issue Payment →
|
|
</a>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Stage detail text --}}
|
|
@if($done || $current)
|
|
<div style="font-size:11px;color:#94a3b8;margin-top:1px;">
|
|
@if($stage === 'draft') Created by {{ $pr->requestedBy->name }} · {{ $pr->created_at->format('d M Y') }}
|
|
@elseif($stage === 'gm_approval' && $pr->signature) Signed by {{ $pr->signature->signedBy->name }} · {{ $pr->signature->signed_at->format('d M Y') }}
|
|
@elseif($stage === 'rfq' || $stage === 'quoting') {{ $pr->rfqInvitations->count() }} supplier(s) invited
|
|
@elseif($stage === 'comparison') {{ $pr->supplierQuotes->count() }} quote(s) received
|
|
@elseif($stage === 'lpo' && $pr->awardedQuote) Awarded to {{ $pr->awardedQuote->supplier->name }}
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/Http/Controllers/Purchase/PurchasePipelineController.php resources/views/purchase/pipeline/
|
|
git commit -m "feat: purchase pipeline index — card list with vertical timeline"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Route wiring + sidebar navigation link
|
|
|
|
**Files:**
|
|
- Modify: `routes/web.php`
|
|
- Modify: `resources/views/layouts/app.blade.php`
|
|
|
|
- [ ] **Step 1: Add all new routes to `routes/web.php`**
|
|
|
|
Add these imports at the top of `web.php` (after existing Purchase imports):
|
|
|
|
```php
|
|
use App\Http\Controllers\Purchase\PurchasePipelineController;
|
|
use App\Http\Controllers\Purchase\PurchaseSignatureController;
|
|
use App\Http\Controllers\Purchase\RfqController;
|
|
use App\Http\Controllers\Purchase\SupplierQuoteController;
|
|
use App\Http\Controllers\Purchase\RfqPortalController;
|
|
```
|
|
|
|
Add the public RFQ route **before** the auth middleware group:
|
|
|
|
```php
|
|
// Public — no auth required
|
|
Route::get('/rfq/{token}', [RfqPortalController::class, 'show'])->name('rfq.show');
|
|
Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.submit');
|
|
```
|
|
|
|
Inside the `purchase.` prefix group, add:
|
|
|
|
```php
|
|
Route::get('pipeline', [PurchasePipelineController::class, 'index'])->name('pipeline.index');
|
|
Route::get('requests/{request}/sign', [PurchaseSignatureController::class, 'show'])->name('requests.sign');
|
|
Route::post('requests/{request}/sign', [PurchaseSignatureController::class, 'store'])->name('requests.sign.store');
|
|
Route::get('requests/{request}/rfq', [RfqController::class, 'show'])->name('requests.rfq');
|
|
Route::post('requests/{request}/rfq', [RfqController::class, 'store'])->name('requests.rfq.store');
|
|
Route::get('requests/{request}/quotes', [SupplierQuoteController::class, 'index'])->name('requests.quotes');
|
|
Route::get('requests/{request}/compare', [SupplierQuoteController::class, 'compare'])->name('requests.compare');
|
|
Route::post('requests/{request}/quotes/{quote}/award', [SupplierQuoteController::class, 'award'])->name('requests.quotes.award');
|
|
```
|
|
|
|
- [ ] **Step 2: Add sidebar link to the layout**
|
|
|
|
Open `resources/views/layouts/app.blade.php`. Find where the sidebar purchase links are. Add a "Pipeline" link as the first item under Purchase:
|
|
|
|
```blade
|
|
<a href="{{ route('purchase.pipeline.index') }}"
|
|
class="{{ request()->routeIs('purchase.pipeline.*') ? 'active' : '' }}">
|
|
Pipeline
|
|
</a>
|
|
```
|
|
|
|
(Match the exact element tag/class used by the rest of the sidebar navigation links in the file.)
|
|
|
|
- [ ] **Step 3: Verify routes load**
|
|
|
|
```
|
|
php artisan route:list --path=purchase/pipeline
|
|
php artisan route:list --path=rfq
|
|
```
|
|
|
|
Expected: `purchase.pipeline.index`, `rfq.show`, `rfq.submit` listed.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add routes/web.php resources/views/layouts/app.blade.php
|
|
git commit -m "feat: wire pipeline routes and sidebar navigation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Seed existing purchase requests with `draft` stage
|
|
|
|
Existing `purchase_requests` rows will have a NULL `stage` (migration sets default `draft` for new rows but existing NULL rows won't match the pipeline logic).
|
|
|
|
**Files:**
|
|
- Create: `database/seeders/PurchaseRequestStagePatchSeeder.php`
|
|
|
|
- [ ] **Step 1: Write the one-time patch seeder**
|
|
|
|
```php
|
|
<?php
|
|
namespace Database\Seeders;
|
|
|
|
use Illuminate\Database\Seeder;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class PurchaseRequestStagePatchSeeder extends Seeder
|
|
{
|
|
public function run(): void
|
|
{
|
|
// Existing approved requests get 'rfq' stage; pending get 'gm_approval'; rest stay 'draft'
|
|
DB::table('purchase_requests')->where('status', 'approved')->whereNull('stage')->update(['stage' => 'rfq']);
|
|
DB::table('purchase_requests')->where('status', 'pending')->whereNull('stage')->update(['stage' => 'gm_approval']);
|
|
DB::table('purchase_requests')->whereNull('stage')->update(['stage' => 'draft']);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the seeder**
|
|
|
|
```
|
|
php artisan db:seed --class=PurchaseRequestStagePatchSeeder
|
|
```
|
|
|
|
Expected: No errors; existing rows now have non-null stage values.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add database/seeders/PurchaseRequestStagePatchSeeder.php
|
|
git commit -m "feat: patch existing purchase_requests with initial stage values"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: GRN receiving view — toggle Inventory / Consumable per item
|
|
|
|
**Files:**
|
|
- Modify: `resources/views/purchase/grns/create.blade.php`
|
|
|
|
This is a targeted edit to the existing GRN creation form. Find the item rows section and add a toggle after the quantity/cost fields for each item.
|
|
|
|
- [ ] **Step 1: Read the existing GRN create view** to understand how item rows are rendered before editing.
|
|
|
|
```
|
|
Read: resources/views/purchase/grns/create.blade.php
|
|
```
|
|
|
|
- [ ] **Step 2: For each item row in the form, add a type toggle**
|
|
|
|
After the existing quantity / unit cost inputs for each GRN item, add:
|
|
|
|
```blade
|
|
<div style="display:flex;gap:0;border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;">
|
|
<label style="flex:1;text-align:center;cursor:pointer;">
|
|
<input type="radio" name="items[{{ $i }}][type]" value="inventory" checked style="display:none;">
|
|
<span class="grn-type-btn" onclick="setGrnType(this,'inventory');"
|
|
style="display:block;padding:6px 8px;font-size:11px;font-weight:700;background:#eff6ff;color:#2563eb;">
|
|
Inventory
|
|
</span>
|
|
</label>
|
|
<label style="flex:1;text-align:center;cursor:pointer;border-left:1px solid #e2e8f0;">
|
|
<input type="radio" name="items[{{ $i }}][type]" value="consumable" style="display:none;">
|
|
<span class="grn-type-btn" onclick="setGrnType(this,'consumable');"
|
|
style="display:block;padding:6px 8px;font-size:11px;font-weight:700;background:#fff;color:#64748b;">
|
|
Consumable
|
|
</span>
|
|
</label>
|
|
</div>
|
|
```
|
|
|
|
And a small JS helper (once in the view, not per row):
|
|
|
|
```javascript
|
|
function setGrnType(span, val) {
|
|
const parent = span.closest('label').parentElement;
|
|
parent.querySelectorAll('.grn-type-btn').forEach(b => {
|
|
b.style.background = '#fff'; b.style.color = '#64748b';
|
|
});
|
|
span.style.background = val === 'inventory' ? '#eff6ff' : '#fef3c7';
|
|
span.style.color = val === 'inventory' ? '#2563eb' : '#92400e';
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update GoodsReceiptNoteController to accept and store the `type` field**
|
|
|
|
In `GoodsReceiptNoteController::store()`, when creating each `GrnItem`, pass `type` from the request:
|
|
|
|
```php
|
|
$item->type = $itemData['type'] ?? 'inventory';
|
|
$item->save();
|
|
```
|
|
|
|
(Read the exact controller code first to insert at the right location.)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add resources/views/purchase/grns/create.blade.php app/Http/Controllers/Purchase/GoodsReceiptNoteController.php
|
|
git commit -m "feat: GRN item type toggle — inventory or consumable"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
**Spec coverage:**
|
|
- [x] Stage 1 (Purchase Request) — existing, no new code needed; stage seeded via Task 12
|
|
- [x] Stage 2 (GM Signature) — Task 6
|
|
- [x] Stage 3 (Select Suppliers + RFQ) — Task 7
|
|
- [x] Stage 4 (Supplier Quotes via private link) — Task 8
|
|
- [x] Stage 5 (Comparison + Award) — Task 9
|
|
- [x] Stage 6 (LPO) — action button links to existing `purchase.orders.create`; no new controller needed
|
|
- [x] Stage 7 (Receiving with inventory/consumable) — Task 13
|
|
- [x] Stage 8 (Payment) — action button links to existing `purchase.payments.create`; no new controller needed
|
|
- [x] Pipeline index view — Task 10
|
|
- [x] Stage advancement via `PurchaseStageService` — Task 5
|
|
- [x] Token expiry, one-submit-only — `RfqPortalController::show()` checks both
|
|
- [x] Awarding is irreversible / locks comparison — `SupplierQuoteController::award()` guard
|
|
- [x] WhatsApp deep link — `RfqInvitationService::whatsappLink()`
|
|
- [x] Email send — `RfqInvitationMail` + `rfq-invitation.blade.php`
|
|
- [x] Public route outside auth — Task 11 adds routes before middleware group
|
|
- [x] `grn_items.type` field — Task 2 + Task 13
|
|
|
|
**Known dependency:** Stage 6 (LPO) and Stage 8 (Payment) action buttons link to existing controllers. Those controllers don't advance the pipeline stage automatically. A follow-up task should add `$stages->setStage($pr, 'receiving')` after LPO creation, and `$stages->setStage($pr, 'complete')` after payment creation. This can be done as a polish pass without blocking the core pipeline.
|