chore: update document approval workflow and bug fixes

This commit is contained in:
2026-05-25 13:05:20 +08:00
parent d573c02893
commit 39a8e1d4cd
910 changed files with 49994 additions and 1010 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace Modules\ApprovalWorkflow\Enums;
enum ApprovalChainStatus: string
{
case Pending = 'pending';
case InReview = 'in_review';
case Approved = 'approved';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::InReview => 'In Review',
self::Approved => 'Approved',
self::Rejected => 'Rejected',
};
}
public function allowedTransitions(): array
{
return match ($this) {
self::Pending => [self::InReview, self::Rejected],
self::InReview => [self::Approved, self::Rejected],
self::Approved => [],
self::Rejected => [self::Pending],
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Modules\ApprovalWorkflow\Enums;
enum ApprovalStepStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
case Skipped = 'skipped';
public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::Approved => 'Approved',
self::Rejected => 'Rejected',
self::Skipped => 'Skipped',
};
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\ApprovalWorkflow\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
class ApprovalCompleted
{
use Dispatchable, SerializesModels;
public function __construct(
public ApprovalChain $chain,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Modules\ApprovalWorkflow\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\ApprovalWorkflow\Models\ApprovalStep;
class ApprovalRequired
{
use Dispatchable, SerializesModels;
public function __construct(
public ApprovalChain $chain,
public ApprovalStep $step,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Modules\ApprovalWorkflow\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\ApprovalWorkflow\Models\ApprovalStep;
class StepApproved
{
use Dispatchable, SerializesModels;
public function __construct(
public ApprovalChain $chain,
public ApprovalStep $step,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Modules\ApprovalWorkflow\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\ApprovalWorkflow\Models\ApprovalStep;
class StepRejected
{
use Dispatchable, SerializesModels;
public function __construct(
public ApprovalChain $chain,
public ApprovalStep $step,
) {}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Modules\ApprovalWorkflow\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\ApprovalWorkflow\Services\ApprovalService;
class ApprovalController extends Controller
{
public function __construct(
private ApprovalService $approvalService,
) {}
/**
* Show pending approvals for the current user.
*/
public function index(Request $request)
{
$user = $request->user();
$tab = $request->get('tab', 'pending');
$query = ApprovalChain::query();
if ($tab === 'history') {
$query->whereHas('steps', function ($q) use ($user) {
$q->where('approver_id', $user->id)->where('status', '!=', 'pending');
});
} else {
$query->whereHas('steps', function ($q) use ($user) {
$q->where('approver_id', $user->id)->where('status', 'pending');
})->whereIn('status', ['pending', 'in_review']);
}
$chains = $query->with(['steps.approver:id,name', 'initiator:id,name'])
->latest()
->paginate(15)
->withQueryString();
return Inertia::render('ApprovalWorkflow::Approvals/Index', [
'approvals' => $chains,
'tab' => $tab,
]);
}
/**
* Show a specific approval chain.
*/
public function show(ApprovalChain $approvalChain)
{
$approvalChain->load(['steps.approver:id,name,email', 'initiator:id,name,email', 'approvable']);
$breakdownData = null;
if ($approvalChain->approvable) {
if (method_exists($approvalChain->approvable, 'items')) {
$approvalChain->approvable->load('items');
}
$breakdownData = [
'document_number' => $approvalChain->approvable->document_number ?? $approvalChain->approvable->po_number ?? null,
'total_cost' => $approvalChain->approvable->total_cost ?? 0,
'notes' => $approvalChain->approvable->notes ?? null,
];
}
return Inertia::render('ApprovalWorkflow::Approvals/Show', [
'chain' => $approvalChain,
'breakdownData' => $breakdownData,
]);
}
/**
* Approve the current step.
*/
public function approve(Request $request, ApprovalChain $approvalChain)
{
$request->validate(['notes' => 'nullable|string|max:1000']);
try {
$this->approvalService->approve($approvalChain, $request->user(), $request->notes);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Step approved successfully.');
}
/**
* Reject the current step (rejects entire chain).
*/
public function reject(Request $request, ApprovalChain $approvalChain)
{
$request->validate(['notes' => 'required|string|max:1000']);
try {
$this->approvalService->reject($approvalChain, $request->user(), $request->notes);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Approval rejected.');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Modules\ApprovalWorkflow\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ApprovalWorkflowController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('approvalworkflow::index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('approvalworkflow::create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request) {}
/**
* Show the specified resource.
*/
public function show($id)
{
return view('approvalworkflow::show');
}
/**
* Show the form for editing the specified resource.
*/
public function edit($id)
{
return view('approvalworkflow::edit');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, $id) {}
/**
* Remove the specified resource from storage.
*/
public function destroy($id) {}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Modules\ApprovalWorkflow\Listeners;
use Modules\ApprovalWorkflow\Events\ApprovalCompleted;
class SyncApprovableStatus
{
/**
* Handle the event.
*/
public function handle(ApprovalCompleted $event): void
{
$chain = $event->chain;
$approvable = $chain->approvable;
if (!$approvable) {
return;
}
// If the model defines a custom hook, use it
if (method_exists($approvable, 'onApprovalCompleted')) {
$approvable->onApprovalCompleted($chain);
return;
}
// Otherwise, attempt a generic status sync
$fillable = $approvable->getFillable();
$updates = [];
if (in_array('status', $fillable)) {
$updates['status'] = $chain->status->value;
}
if ($chain->status->value === 'approved') {
if (in_array('approved_by', $fillable)) {
$lastStep = $chain->steps()->where('status', 'approved')->orderByDesc('acted_at')->first();
if ($lastStep) {
$updates['approved_by'] = $lastStep->approver_id;
}
}
if (in_array('approved_at', $fillable)) {
$lastStep = $chain->steps()->where('status', 'approved')->orderByDesc('acted_at')->first();
if ($lastStep) {
$updates['approved_at'] = $lastStep->acted_at;
} else {
$updates['approved_at'] = now();
}
}
}
if (!empty($updates)) {
$approvable->update($updates);
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Modules\ApprovalWorkflow\Models;
use App\Models\User;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Modules\ApprovalWorkflow\Enums\ApprovalChainStatus;
class ApprovalChain extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'approvable_type',
'approvable_id',
'type',
'status',
'notes',
'initiated_by',
'completed_at',
];
protected function casts(): array
{
return [
'status' => ApprovalChainStatus::class,
'completed_at' => 'datetime',
];
}
public function approvable(): MorphTo
{
return $this->morphTo();
}
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by');
}
public function steps(): HasMany
{
return $this->hasMany(ApprovalStep::class)->orderBy('order');
}
public function currentStep(): ?ApprovalStep
{
return $this->steps()->where('status', 'pending')->first();
}
public function isFullyApproved(): bool
{
return $this->steps()->where('status', '!=', 'approved')->doesntExist();
}
public function transitionTo(ApprovalChainStatus $newStatus): void
{
$allowed = $this->status->allowedTransitions();
if (!in_array($newStatus, $allowed)) {
throw new \InvalidArgumentException(
"Cannot transition chain from {$this->status->label()} to {$newStatus->label()}"
);
}
$updates = ['status' => $newStatus];
if (in_array($newStatus, [ApprovalChainStatus::Approved, ApprovalChainStatus::Rejected])) {
$updates['completed_at'] = now();
}
$this->update($updates);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Modules\ApprovalWorkflow\Models;
use App\Models\User;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\ApprovalWorkflow\Enums\ApprovalStepStatus;
class ApprovalStep extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'approval_chain_id',
'approver_id',
'order',
'status',
'notes',
'acted_at',
];
protected function casts(): array
{
return [
'status' => ApprovalStepStatus::class,
'acted_at' => 'datetime',
];
}
public function chain(): BelongsTo
{
return $this->belongsTo(ApprovalChain::class, 'approval_chain_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approver_id');
}
public function isPending(): bool
{
return $this->status === ApprovalStepStatus::Pending;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Modules\ApprovalWorkflow\Providers;
use Nwidart\Modules\Support\ModuleServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
class ApprovalWorkflowServiceProvider extends ModuleServiceProvider
{
/**
* The name of the module.
*/
protected string $name = 'ApprovalWorkflow';
/**
* The lowercase version of the module name.
*/
protected string $nameLower = 'approvalworkflow';
/**
* Command classes to register.
*
* @var string[]
*/
// protected array $commands = [];
/**
* Provider classes to register.
*
* @var string[]
*/
protected array $providers = [
EventServiceProvider::class,
RouteServiceProvider::class,
];
/**
* Define module schedules.
*
* @param $schedule
*/
// protected function configureSchedules(Schedule $schedule): void
// {
// $schedule->command('inspire')->hourly();
// }
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Modules\ApprovalWorkflow\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [
\Modules\ApprovalWorkflow\Events\ApprovalCompleted::class => [
\Modules\ApprovalWorkflow\Listeners\SyncApprovableStatus::class,
],
];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*/
protected function configureEmailVerification(): void {}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Modules\ApprovalWorkflow\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected string $name = 'ApprovalWorkflow';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*/
public function boot(): void
{
parent::boot();
}
/**
* Define the routes for the application.
*/
public function map(): void
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*/
protected function mapWebRoutes(): void
{
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapApiRoutes(): void
{
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Modules\ApprovalWorkflow\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Modules\ApprovalWorkflow\Enums\ApprovalChainStatus;
use Modules\ApprovalWorkflow\Enums\ApprovalStepStatus;
use Modules\ApprovalWorkflow\Events\ApprovalCompleted;
use Modules\ApprovalWorkflow\Events\ApprovalRequired;
use Modules\ApprovalWorkflow\Events\StepApproved;
use Modules\ApprovalWorkflow\Events\StepRejected;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\ApprovalWorkflow\Models\ApprovalStep;
class ApprovalService
{
/**
* Create a new approval chain for any morphable model.
*
* @param array<int, int> $approverIds ordered list of user IDs
*/
public function createChain(
Model $approvable,
array $approverIds,
string $type = 'general',
?int $initiatedBy = null,
?string $notes = null,
): ApprovalChain {
$chain = ApprovalChain::create([
'approvable_type' => $approvable->getMorphClass(),
'approvable_id' => $approvable->getKey(),
'type' => $type,
'status' => ApprovalChainStatus::Pending,
'initiated_by' => $initiatedBy,
'notes' => $notes,
]);
foreach ($approverIds as $order => $userId) {
ApprovalStep::create([
'approval_chain_id' => $chain->id,
'approver_id' => $userId,
'order' => $order,
'status' => ApprovalStepStatus::Pending,
]);
}
// Auto-transition to InReview and notify first approver
$chain->transitionTo(ApprovalChainStatus::InReview);
$firstStep = $chain->currentStep();
if ($firstStep) {
ApprovalRequired::dispatch($chain, $firstStep);
}
return $chain->load('steps.approver');
}
/**
* Approve the current pending step.
*/
public function approve(ApprovalChain $chain, User $approver, ?string $notes = null): ApprovalChain
{
$step = $this->getCurrentStepForApprover($chain, $approver);
$step->update([
'status' => ApprovalStepStatus::Approved,
'notes' => $notes,
'acted_at' => now(),
]);
StepApproved::dispatch($chain, $step);
// Check if all steps are approved
if ($chain->isFullyApproved()) {
$chain->transitionTo(ApprovalChainStatus::Approved);
ApprovalCompleted::dispatch($chain);
} else {
// Notify next approver
$nextStep = $chain->currentStep();
if ($nextStep) {
ApprovalRequired::dispatch($chain, $nextStep);
}
}
return $chain->fresh('steps.approver');
}
/**
* Reject the current pending step (rejects the entire chain).
*/
public function reject(ApprovalChain $chain, User $approver, ?string $notes = null): ApprovalChain
{
$step = $this->getCurrentStepForApprover($chain, $approver);
$step->update([
'status' => ApprovalStepStatus::Rejected,
'notes' => $notes,
'acted_at' => now(),
]);
// Skip remaining steps
$chain->steps()
->where('order', '>', $step->order)
->where('status', 'pending')
->update(['status' => ApprovalStepStatus::Skipped->value]);
$chain->transitionTo(ApprovalChainStatus::Rejected);
StepRejected::dispatch($chain, $step);
ApprovalCompleted::dispatch($chain);
return $chain->fresh('steps.approver');
}
public function getNextApprover(ApprovalChain $chain): ?User
{
$step = $chain->currentStep();
return $step?->approver;
}
private function getCurrentStepForApprover(ApprovalChain $chain, User $approver): ApprovalStep
{
$step = $chain->currentStep();
if (!$step) {
throw new \InvalidArgumentException('No pending step found in this approval chain.');
}
if ($step->approver_id !== $approver->id) {
throw new \InvalidArgumentException('You are not the current approver for this step.');
}
return $step;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Modules\ApprovalWorkflow\Traits;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
trait HasApprovable
{
public function approvalChains()
{
return $this->morphMany(ApprovalChain::class, 'approvable');
}
public function latestApprovalChain(): ?ApprovalChain
{
return $this->approvalChains()->latest()->first();
}
public function isApproved(): bool
{
$chain = $this->latestApprovalChain();
return $chain && $chain->status->value === 'approved';
}
public function isPendingApproval(): bool
{
$chain = $this->latestApprovalChain();
return $chain && in_array($chain->status->value, ['pending', 'in_review']);
}
}

View File

@@ -0,0 +1,30 @@
{
"name": "nwidart/approvalworkflow",
"description": "",
"authors": [
{
"name": "Nicolas Widart",
"email": "n.widart@gmail.com"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"autoload": {
"psr-4": {
"Modules\\ApprovalWorkflow\\": "app/",
"Modules\\ApprovalWorkflow\\Database\\Factories\\": "database/factories/",
"Modules\\ApprovalWorkflow\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Modules\\ApprovalWorkflow\\Tests\\": "tests/"
}
}
}

View File

View File

@@ -0,0 +1,5 @@
<?php
return [
'name' => 'ApprovalWorkflow',
];

View File

@@ -0,0 +1,39 @@
<?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('approval_chains', function (Blueprint $table) {
$table->id();
$table->morphs('approvable'); // approvable_type + approvable_id
$table->string('type')->default('general'); // e.g., material_request, invoice, purchase_order
$table->string('status')->default('pending');
$table->text('notes')->nullable();
$table->foreignId('initiated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
Schema::create('approval_steps', function (Blueprint $table) {
$table->id();
$table->foreignId('approval_chain_id')->constrained()->cascadeOnDelete();
$table->foreignId('approver_id')->constrained('users')->cascadeOnDelete();
$table->integer('order')->default(0);
$table->string('status')->default('pending');
$table->text('notes')->nullable();
$table->timestamp('acted_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('approval_steps');
Schema::dropIfExists('approval_chains');
}
};

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\ApprovalWorkflow\Database\Seeders;
use Illuminate\Database\Seeder;
class ApprovalWorkflowDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// $this->call([]);
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "ApprovalWorkflow",
"alias": "approvalworkflow",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\ApprovalWorkflow\\Providers\\ApprovalWorkflowServiceProvider"
],
"files": []
}

View File

@@ -0,0 +1,15 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.5",
"sass": "^1.69.5",
"postcss": "^8.3.7",
"vite": "^4.0.0"
}
}

View File

@@ -0,0 +1,83 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Button } from '@/Components/ui/button';
import { ExternalLink, FileText, ShoppingCart } from 'lucide-react';
interface Props {
chain: any;
breakdownData?: any;
}
export default function ApprovableBreakdown({ chain, breakdownData }: Props) {
const approvable = chain.approvable;
if (!approvable || !breakdownData) return null;
let title = 'Document Breakdown';
let icon = <FileText className="h-5 w-5 mr-2" />;
let details: { label: string; value: React.ReactNode }[] = [];
let linkUrl = '#';
switch (chain.approvable_type) {
case 'Modules\\MaterialLogistics\\Models\\MaterialRequisition':
title = 'Material Requisition Details';
icon = <ShoppingCart className="h-5 w-5 mr-2" />;
linkUrl = route('requisitions.show', approvable.ulid || approvable.id);
details = [
{ label: 'Document Number', value: breakdownData.document_number || `REQ-${approvable.id}` },
{ label: 'Total Cost', value: breakdownData.total_cost != null ? `${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' },
{ label: 'Notes', value: breakdownData.notes || 'None' },
];
break;
case 'Modules\\MaterialLogistics\\Models\\PurchaseOrder':
title = 'Purchase Order Details';
icon = <ShoppingCart className="h-5 w-5 mr-2" />;
linkUrl = route('purchase-orders.show', approvable.ulid || approvable.id);
details = [
{ label: 'PO Number', value: breakdownData.document_number || `PO-${approvable.id}` },
{ label: 'Total Amount', value: breakdownData.total_cost != null ? `${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' },
{ label: 'Notes', value: breakdownData.notes || 'None' },
];
break;
default:
title = 'Generic Document';
break;
}
return (
<Card className="mb-6 border-primary bg-primary/5 shadow-sm">
<CardHeader className="flex flex-row justify-between items-center pb-2">
<CardTitle className="text-lg flex items-center text-primary">
{icon} {title}
</CardTitle>
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
<Button variant="default" size="sm" className="hidden sm:flex">
<ExternalLink className="h-4 w-4 mr-2" />
View Source Document
</Button>
</a>
</CardHeader>
<CardContent>
{details.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
{details.map((detail, idx) => (
<div key={idx}>
<p className="text-xs text-muted-foreground">{detail.label}</p>
<p className="font-medium text-sm">{detail.value}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Details not found or unsupported document type.</p>
)}
<div className="mt-4 sm:hidden">
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
<Button variant="default" size="sm" className="w-full">
<ExternalLink className="h-4 w-4 mr-2" />
View Source Document
</Button>
</a>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,66 @@
import { Badge } from '@/Components/ui/badge';
import { CheckCircle2, Circle, XCircle, SkipForward } from 'lucide-react';
interface Step {
id: number; ulid: string;
order: number;
status: string;
notes?: string;
acted_at?: string;
approver: { id: number; ulid: string; name: string };
}
interface Props {
steps: Step[];
}
const statusConfig: Record<string, { icon: React.ComponentType<{ className?: string }>; color: string; bgColor: string }> = {
pending: { icon: Circle, color: 'text-gray-400', bgColor: 'bg-gray-100' },
approved: { icon: CheckCircle2, color: 'text-green-600', bgColor: 'bg-green-50' },
rejected: { icon: XCircle, color: 'text-red-600', bgColor: 'bg-red-50' },
skipped: { icon: SkipForward, color: 'text-gray-400', bgColor: 'bg-gray-50' },
};
export default function ApprovalTimeline({ steps }: Props) {
return (
<div className="space-y-0">
{steps.map((step, idx) => {
const config = statusConfig[step.status] || statusConfig.pending;
const Icon = config.icon;
const isLast = idx === steps.length - 1;
return (
<div key={step.id} className="flex gap-3">
{/* Timeline line + icon */}
<div className="flex flex-col items-center">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${config.bgColor}`}>
<Icon className={`h-4 w-4 ${config.color}`} />
</div>
{!isLast && (
<div className="w-px flex-1 bg-gray-200 min-h-[24px]" />
)}
</div>
{/* Content */}
<div className="pb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{step.approver.name}</span>
<Badge variant={step.status === 'approved' ? 'default' : step.status === 'rejected' ? 'destructive' : 'outline'} className="text-xs">
{step.status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
</div>
{step.notes && (
<p className="mt-1 text-xs text-gray-500 italic">"{step.notes}"</p>
)}
{step.acted_at && (
<p className="mt-0.5 text-xs text-gray-400">
{new Date(step.acted_at).toLocaleString()}
</p>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { PaginatedData, PageProps } from '@/types';
import { ClipboardCheck, Eye } from 'lucide-react';
interface ChainItem {
id: number; ulid: string;
approvable_type: string;
type: string;
status: string;
notes?: string;
created_at: string;
initiator?: { id: number; ulid: string; name: string };
steps: {
id: number; ulid: string; order: number; status: string;
approver: { id: number; ulid: string; name: string };
}[];
}
interface Props extends PageProps {
approvals: PaginatedData<ChainItem>;
tab: string;
}
const typeLabel = (type: string) => type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const statusVariant = (s: string) => {
switch (s) {
case 'approved': return 'default';
case 'rejected': return 'destructive';
case 'in_review': return 'secondary';
default: return 'outline';
}
};
export default function Index({ approvals, tab }: Props) {
const { flash } = usePage<PageProps>().props;
const isHistory = tab === 'history';
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">
{isHistory ? 'Approval History' : 'Pending Approvals'}
</h2>
</div>
}
>
<Head title={isHistory ? 'Approval History' : 'Pending Approvals'} />
<div className="py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
<div className="mb-6 flex space-x-8 border-b border-gray-200">
<Link href={route('approvals.index', { tab: 'pending' })} className={`pb-4 px-1 border-b-2 font-medium text-sm ${!isHistory ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
Pending Approvals
</Link>
<Link href={route('approvals.index', { tab: 'history' })} className={`pb-4 px-1 border-b-2 font-medium text-sm ${isHistory ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
Approval History
</Link>
</div>
<Card>
<CardHeader><CardTitle>{isHistory ? 'Your Past Approvals' : 'Items Awaiting Your Approval'}</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Initiated By</TableHead>
<TableHead>Status</TableHead>
<TableHead>Steps</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{approvals.data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
{isHistory ? 'No past approvals found.' : "No pending approvals. You're all caught up! 🎉"}
</TableCell>
</TableRow>
) : (
approvals.data.map((chain) => (
<TableRow key={chain.id}>
<TableCell className="font-medium">{typeLabel(chain.type)}</TableCell>
<TableCell className="text-gray-500">{chain.initiator?.name || '-'}</TableCell>
<TableCell>
<Badge variant={statusVariant(chain.status)}>
{typeLabel(chain.status)}
</Badge>
</TableCell>
<TableCell>
{chain.steps.filter(s => s.status === 'approved').length}/{chain.steps.length}
</TableCell>
<TableCell className="text-gray-500">
{new Date(chain.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<Link href={route('approvals.show', chain.ulid)}>
<Button variant="ghost" size="icon-sm" title="Review">
<Eye className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{approvals.last_page > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing {approvals.from} to {approvals.to} of {approvals.total}
</p>
<div className="flex gap-1">
{approvals.prev_page_url && (
<Link href={approvals.prev_page_url}>
<Button variant="outline" size="sm">Previous</Button>
</Link>
)}
{approvals.next_page_url && (
<Link href={approvals.next_page_url}>
<Button variant="outline" size="sm">Next</Button>
</Link>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,157 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import ApprovalTimeline from '../../Components/ApprovalTimeline';
import ApprovableBreakdown from '../../Components/ApprovableBreakdown';
import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react';
import { PageProps } from '@/types';
import { useState } from 'react';
interface Step {
id: number; ulid: string; order: number; status: string; notes?: string;
acted_at?: string; approver: { id: number; ulid: string; name: string; email: string };
}
interface ChainData {
id: number; ulid: string; approvable_type: string; type: string; status: string;
notes?: string; created_at: string; completed_at?: string;
initiator?: { id: number; ulid: string; name: string; email: string };
steps: Step[];
approvable?: any;
}
interface Props extends PageProps {
chain: ChainData;
breakdownData: any;
}
const typeLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const statusVariant = (s: string) => {
switch (s) { case 'approved': return 'default'; case 'rejected': return 'destructive'; case 'in_review': return 'secondary'; default: return 'outline'; }
};
export default function Show({ chain, breakdownData }: Props) {
const { flash, auth } = usePage<PageProps>().props;
const [approveNotes, setApproveNotes] = useState('');
const [rejectNotes, setRejectNotes] = useState('');
const currentStep = chain.steps.find(s => s.status === 'pending');
const isCurrentApprover = currentStep && currentStep.approver.id === auth.user.id;
const handleApprove = () => {
router.patch(route('approvals.approve', chain.ulid), { notes: approveNotes || undefined });
};
const handleReject = () => {
if (!rejectNotes.trim()) {
alert('Please provide a reason for rejection.');
return;
}
router.patch(route('approvals.reject', chain.ulid), { notes: rejectNotes });
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-4">
<Link href={route('approvals.index')}>
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<div>
<h2 className="text-xl font-semibold leading-tight text-gray-800">
Approval: {typeLabel(chain.type)} #{chain.id}
</h2>
</div>
<Badge variant={statusVariant(chain.status)}>{typeLabel(chain.status)}</Badge>
</div>
}
>
<Head title={`Approval #${chain.id}`} />
<div className="py-6">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
{/* Chain Info */}
<Card className="mb-6">
<CardHeader><CardTitle>Details</CardTitle></CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-gray-500">Type</p>
<p className="font-medium">{typeLabel(chain.type)}</p>
</div>
<div>
<p className="text-xs text-gray-500">Initiated By</p>
<p className="font-medium">{chain.initiator?.name || 'System'}</p>
</div>
<div>
<p className="text-xs text-gray-500">Submitted</p>
<p>{new Date(chain.created_at).toLocaleString()}</p>
</div>
{chain.completed_at && (
<div>
<p className="text-xs text-gray-500">Completed</p>
<p>{new Date(chain.completed_at).toLocaleString()}</p>
</div>
)}
</div>
{chain.notes && (
<div className="mt-4">
<p className="text-xs text-gray-500">Notes</p>
<p className="text-sm mt-1">{chain.notes}</p>
</div>
)}
</CardContent>
</Card>
{/* Approvable Breakdown */}
<ApprovableBreakdown chain={chain} breakdownData={breakdownData} />
{/* Timeline */}
<Card className="mb-6">
<CardHeader><CardTitle>Approval Progress</CardTitle></CardHeader>
<CardContent>
<ApprovalTimeline steps={chain.steps} />
</CardContent>
</Card>
{/* Action Buttons */}
{isCurrentApprover && chain.status === 'in_review' && (
<Card>
<CardHeader><CardTitle>Your Action Required</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="approve_notes">Review Notes</Label>
<textarea
id="approve_notes"
className="mt-1 flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={approveNotes}
onChange={(e) => {
setApproveNotes(e.target.value);
setRejectNotes(e.target.value);
}}
placeholder="Optional for approval, required for rejection..."
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="destructive" onClick={handleReject}>
<XCircle className="mr-2 h-4 w-4" /> Reject
</Button>
<Button onClick={handleApprove}>
<CheckCircle2 className="mr-2 h-4 w-4" /> Approve
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>ApprovalWorkflow Module - {{ config('app.name', 'Laravel') }}</title>
<meta name="description" content="{{ $description ?? '' }}">
<meta name="keywords" content="{{ $keywords ?? '' }}">
<meta name="author" content="{{ $author ?? '' }}">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
{{-- Vite CSS --}}
{{-- {{ module_vite('build-approvalworkflow', 'resources/assets/sass/app.scss') }} --}}
</head>
<body>
{{ $slot }}
{{-- Vite JS --}}
{{-- {{ module_vite('build-approvalworkflow', 'resources/assets/js/app.js') }} --}}
</body>
</html>

View File

@@ -0,0 +1,5 @@
<x-approvalworkflow::layouts.master>
<h1>Hello World</h1>
<p>Module: {!! config('approvalworkflow.name') !!}</p>
</x-approvalworkflow::layouts.master>

View File

View File

@@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\ApprovalWorkflow\Http\Controllers\ApprovalWorkflowController;
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
Route::apiResource('approvalworkflows', ApprovalWorkflowController::class)->names('approvalworkflow');
});

View File

@@ -0,0 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\ApprovalWorkflow\Http\Controllers\ApprovalController;
Route::middleware(['web', 'auth', 'permission:approvals.access'])->group(function () {
Route::get('approvals', [ApprovalController::class, 'index'])->name('approvals.index');
Route::get('approvals/{approvalChain}', [ApprovalController::class, 'show'])->name('approvals.show');
Route::patch('approvals/{approvalChain}/approve', [ApprovalController::class, 'approve'])->name('approvals.approve');
Route::patch('approvals/{approvalChain}/reject', [ApprovalController::class, 'reject'])->name('approvals.reject');
});

View File

@@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
// Uncomment the import for your frontend framework:
// import vue from '@vitejs/plugin-vue';
// import react from '@vitejs/plugin-react';
// import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
build: {
outDir: '../../public/build-approvalworkflow',
emptyOutDir: true,
manifest: true,
},
plugins: [
laravel({
publicDirectory: '../../public',
buildDirectory: 'build-approvalworkflow',
input: [
__dirname + '/resources/assets/sass/app.scss',
__dirname + '/resources/assets/js/app.js'
],
refresh: true,
}),
// Uncomment the plugin for your frontend framework:
// vue({
// template: {
// transformAssetUrls: {
// base: null,
// includeAbsolute: false,
// },
// },
// }),
// react(),
// svelte(),
],
resolve: {
alias: {
'@': __dirname + '/resources/js',
},
},
});

View File

@@ -0,0 +1,45 @@
<?php
namespace Modules\BiddingManagement\Enums;
enum BidPackageStatus: string
{
case Draft = 'draft';
case Open = 'open';
case Evaluating = 'evaluating';
case Awarded = 'awarded';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Open => 'Open',
self::Evaluating => 'Evaluating',
self::Awarded => 'Awarded',
self::Cancelled => 'Cancelled',
};
}
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::Open, self::Cancelled],
self::Open => [self::Evaluating, self::Cancelled],
self::Evaluating => [self::Awarded, self::Cancelled],
self::Awarded => [],
self::Cancelled => [],
};
}
public function color(): string
{
return match ($this) {
self::Draft => 'secondary',
self::Open => 'default',
self::Evaluating => 'warning',
self::Awarded => 'success',
self::Cancelled => 'destructive',
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Modules\BiddingManagement\Enums;
enum BidSubmissionStatus: string
{
case Submitted = 'submitted';
case Shortlisted = 'shortlisted';
case Rejected = 'rejected';
case Awarded = 'awarded';
public function label(): string
{
return match ($this) {
self::Submitted => 'Submitted',
self::Shortlisted => 'Shortlisted',
self::Rejected => 'Rejected',
self::Awarded => 'Awarded',
};
}
public function allowedTransitions(): array
{
return match ($this) {
self::Submitted => [self::Shortlisted, self::Rejected],
self::Shortlisted => [self::Awarded, self::Rejected],
self::Rejected => [],
self::Awarded => [],
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Modules\BiddingManagement\Enums;
enum EvaluationMode: string
{
case Simple = 'simple';
case Scored = 'scored';
public function label(): string
{
return match ($this) {
self::Simple => 'Simple Selection',
self::Scored => 'Weighted Scorecard',
};
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Modules\BiddingManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\BiddingManagement\Models\BidAward;
class BidAwarded
{
use Dispatchable, SerializesModels;
public function __construct(public readonly BidAward $award) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Modules\BiddingManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\BiddingManagement\Models\BidPackage;
class BidPackageOpened
{
use Dispatchable, SerializesModels;
public function __construct(public readonly BidPackage $package) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Modules\BiddingManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\BiddingManagement\Models\BidSubmission;
class BidSubmitted
{
use Dispatchable, SerializesModels;
public function __construct(public readonly BidSubmission $submission) {}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Modules\BiddingManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Events\BidAwarded;
use Modules\BiddingManagement\Models\BidAward;
use Modules\BiddingManagement\Models\BidPackage;
use Modules\BiddingManagement\Models\BidSubmission;
class BidAwardController extends Controller
{
public function store(Request $request, BidPackage $bid)
{
abort_unless(
in_array($bid->status, [BidPackageStatus::Open, BidPackageStatus::Evaluating]),
403,
'Award can only be made from an open or evaluating package.'
);
$validated = $request->validate([
'submission_id' => 'required|string',
'notes' => 'nullable|string|max:1000',
]);
$submission = BidSubmission::findByUlid($validated['submission_id']);
abort_if(!$submission, 404, 'Submission not found.');
abort_unless(
$submission->invitation->bid_package_id === $bid->id,
422,
'Submission does not belong to this bid package.'
);
$award = BidAward::createAward(
$bid,
$submission,
Auth::user(),
$validated['notes'] ?? null
);
BidAwarded::dispatch($award);
return redirect()
->route('bids.show', $bid)
->with('success', 'Contract awarded successfully. Project contractor has been updated.');
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Modules\BiddingManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Models\BidInvitation;
use Modules\BiddingManagement\Models\BidPackage;
use Modules\ContractorManagement\Models\Contractor;
class BidInvitationController extends Controller
{
public function store(Request $request, BidPackage $bid)
{
abort_unless(
in_array($bid->status, [BidPackageStatus::Draft, BidPackageStatus::Open]),
403,
'Invitations can only be added to draft or open packages.'
);
$validated = $request->validate([
'contractor_ids' => 'required|array|min:1',
'contractor_ids.*' => 'required|string',
]);
$added = 0;
foreach ($validated['contractor_ids'] as $ulid) {
$contractorId = Contractor::resolveUlidToId($ulid);
if (!$contractorId) {
continue;
}
// Skip duplicates
$exists = $bid->invitations()->where('contractor_id', $contractorId)->exists();
if ($exists) {
continue;
}
$bid->invitations()->create([
'contractor_id' => $contractorId,
'status' => 'invited',
'invited_at' => now(),
]);
$added++;
}
return back()->with('success', "{$added} contractor(s) invited.");
}
public function destroy(BidInvitation $invitation)
{
abort_unless(
in_array($invitation->package->status, [BidPackageStatus::Draft, BidPackageStatus::Open]),
403,
'Cannot remove invitations at this stage.'
);
abort_if($invitation->submission()->exists(), 422, 'Cannot remove an invitation that has a submission.');
$invitation->delete();
return back()->with('success', 'Invitation removed.');
}
public function accept(BidInvitation $invitation)
{
abort_unless($invitation->package->status === BidPackageStatus::Open, 403, 'Bid package is not open.');
abort_if($invitation->status === 'declined', 422, 'You have already declined this invitation.');
$invitation->accept();
return back()->with('success', 'You have accepted the bid invitation.');
}
public function decline(Request $request, BidInvitation $invitation)
{
abort_unless($invitation->package->status === BidPackageStatus::Open, 403, 'Bid package is not open.');
$validated = $request->validate([
'reason' => 'nullable|string|max:500',
]);
$invitation->decline($validated['reason'] ?? null);
return back()->with('success', 'You have declined the bid invitation.');
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace Modules\BiddingManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Events\BidPackageOpened;
use Modules\BiddingManagement\Models\BidPackage;
use Modules\ContractorManagement\Models\Contractor;
use Modules\ProjectManagement\Models\Project;
class BidPackageController extends Controller
{
public function index(Request $request)
{
$query = BidPackage::with(['project:id,name,code', 'creator:id,name'])
->withCount(['invitations', 'submissions']);
if ($search = $request->search) {
$query->where('title', 'like', "%{$search}%");
}
if ($status = $request->status) {
$query->where('status', $status);
}
if ($mode = $request->evaluation_mode) {
$query->where('evaluation_mode', $mode);
}
if ($projectUlid = $request->project) {
$projectId = Project::resolveUlidToId($projectUlid);
$query->where('project_id', $projectId);
}
$packages = $query->latest()->paginate(15)->withQueryString();
return Inertia::render('BiddingManagement::Bids/Index', [
'packages' => $packages,
'filters' => $request->only(['search', 'status', 'evaluation_mode', 'project']),
'projects' => Project::select('id', 'ulid', 'name', 'code')->get(),
]);
}
public function create()
{
return Inertia::render('BiddingManagement::Bids/Create', [
'projects' => Project::select('id', 'ulid', 'name', 'code')->get(),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'project_id' => 'required|string',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'evaluation_mode' => 'required|in:simple,scored',
'submission_deadline' => 'nullable|date',
'validity_period' => 'nullable|date',
'instructions' => 'nullable|string',
'criteria' => 'nullable|array',
'criteria.*.name' => 'required_with:criteria|string|max:100',
'criteria.*.weight' => 'required_with:criteria|numeric|min:0|max:100',
'criteria.*.description' => 'nullable|string',
]);
$validated['project_id'] = Project::resolveUlidToId($validated['project_id']);
$validated['created_by'] = Auth::id();
$criteria = $validated['criteria'] ?? [];
unset($validated['criteria']);
if ($validated['evaluation_mode'] === 'scored' && count($criteria) > 0) {
$totalWeight = array_sum(array_column($criteria, 'weight'));
if ($totalWeight > 100) {
return back()->withErrors(['criteria' => "Total criteria weight ({$totalWeight}%) exceeds 100%."]);
}
}
$package = BidPackage::create($validated);
foreach ($criteria as $index => $criterion) {
$package->criteria()->create([
'name' => $criterion['name'],
'weight' => $criterion['weight'],
'description' => $criterion['description'] ?? null,
'sort_order' => $index,
]);
}
return redirect()->route('bids.show', $package)->with('success', 'Bid package created.');
}
public function show(BidPackage $bid)
{
$bid->load([
'project:id,ulid,name,code',
'creator:id,name',
'criteria',
'invitations.contractor:id,ulid,company_name,specialization,rating',
'invitations.submission.scores',
'invitations.submission.documents',
'award.submission.invitation.contractor:id,company_name',
'award.awardedBy:id,name',
'documents',
]);
$contractors = Contractor::where('status', 'active')
->select('id', 'ulid', 'company_name', 'specialization', 'rating')
->get();
return Inertia::render('BiddingManagement::Bids/Show', [
'package' => $bid,
'contractors' => $contractors,
]);
}
public function edit(BidPackage $bid)
{
abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be edited.');
$bid->load('criteria');
return Inertia::render('BiddingManagement::Bids/Edit', [
'package' => $bid,
'projects' => Project::select('id', 'ulid', 'name', 'code')->get(),
]);
}
public function update(Request $request, BidPackage $bid)
{
abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be edited.');
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'evaluation_mode' => 'required|in:simple,scored',
'submission_deadline' => 'nullable|date',
'validity_period' => 'nullable|date',
'instructions' => 'nullable|string',
'criteria' => 'nullable|array',
'criteria.*.name' => 'required_with:criteria|string|max:100',
'criteria.*.weight' => 'required_with:criteria|numeric|min:0|max:100',
'criteria.*.description' => 'nullable|string',
]);
$criteria = $validated['criteria'] ?? [];
unset($validated['criteria']);
$bid->update($validated);
// Replace criteria wholesale
$bid->criteria()->delete();
foreach ($criteria as $index => $criterion) {
$bid->criteria()->create([
'name' => $criterion['name'],
'weight' => $criterion['weight'],
'description' => $criterion['description'] ?? null,
'sort_order' => $index,
]);
}
return redirect()->route('bids.show', $bid)->with('success', 'Bid package updated.');
}
public function destroy(BidPackage $bid)
{
abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be deleted.');
$bid->delete();
return redirect()->route('bids.index')->with('success', 'Bid package deleted.');
}
public function publish(BidPackage $bid)
{
try {
$bid->transitionTo(BidPackageStatus::Open);
BidPackageOpened::dispatch($bid);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Bid package is now open for submissions.');
}
public function startEvaluation(BidPackage $bid)
{
try {
$bid->transitionTo(BidPackageStatus::Evaluating);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Evaluation phase started.');
}
public function cancel(BidPackage $bid)
{
try {
$bid->transitionTo(BidPackageStatus::Cancelled);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Bid package cancelled.');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Modules\BiddingManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Models\BidScore;
use Modules\BiddingManagement\Models\BidSubmission;
class BidScoreController extends Controller
{
public function store(Request $request, BidSubmission $submission)
{
$package = $submission->invitation->package;
abort_unless($package->status === BidPackageStatus::Evaluating, 403, 'Package is not in evaluation phase.');
$validated = $request->validate([
'scores' => 'required|array',
'scores.*.criteria_id' => 'required|integer|exists:bid_criteria,id',
'scores.*.score' => 'required|numeric|min:0|max:100',
'scores.*.notes' => 'nullable|string|max:500',
]);
foreach ($validated['scores'] as $entry) {
BidScore::updateOrCreate(
[
'bid_submission_id' => $submission->id,
'bid_criteria_id' => $entry['criteria_id'],
],
[
'evaluated_by' => Auth::id(),
'score' => $entry['score'],
'notes' => $entry['notes'] ?? null,
]
);
}
return back()->with('success', 'Scores saved.');
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Modules\BiddingManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\ContractorManagement\Models\Contractor;
use Inertia\Inertia;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Enums\BidSubmissionStatus;
use Modules\BiddingManagement\Events\BidSubmitted;
use Modules\BiddingManagement\Models\BidInvitation;
use Modules\BiddingManagement\Models\BidSubmission;
class BidSubmissionController extends Controller
{
public function create(BidInvitation $invitation)
{
abort_unless($invitation->package->status === BidPackageStatus::Open, 403, 'Bid package is not open.');
abort_if($invitation->submission()->exists(), 422, 'You have already submitted a bid.');
$invitation->load('package.project:id,name,code', 'package.criteria');
return Inertia::render('BiddingManagement::Bids/Contractor/Submit', [
'invitation' => $invitation,
]);
}
public function store(Request $request, BidInvitation $invitation)
{
abort_unless($invitation->package->status === BidPackageStatus::Open, 403, 'Bid package is not open.');
abort_if($invitation->submission()->exists(), 422, 'You have already submitted a bid.');
$validated = $request->validate([
'submitted_price' => 'required|numeric|min:0',
'completion_days' => 'required|integer|min:1',
'validity_date' => 'nullable|date|after_or_equal:today',
'technical_notes' => 'nullable|string',
'documents' => 'nullable|array',
'documents.*' => 'file|max:20480|mimes:pdf,doc,docx,xls,xlsx,png,jpg,jpeg',
]);
// Accept invitation if still invited
if ($invitation->status === 'invited') {
$invitation->accept();
}
$documents = $validated['documents'] ?? [];
unset($validated['documents']);
$validated['bid_invitation_id'] = $invitation->id;
$validated['submitted_at'] = now();
$submission = BidSubmission::create($validated);
// Handle document uploads via DocumentManagement pattern
foreach ($documents as $file) {
$path = $file->store('bid-submissions', 'public');
$document = $submission->documents()->create([
'title' => $file->getClientOriginalName(),
'category' => 'bid_document',
'documentable_type' => BidSubmission::class,
'documentable_id' => $submission->id,
'uploaded_by' => Auth::id(),
'current_file_path' => $path,
'current_file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'version_count' => 1,
]);
}
BidSubmitted::dispatch($submission);
return redirect()
->route('bids.my')
->with('success', 'Your bid has been submitted successfully.');
}
public function show(BidSubmission $submission)
{
$submission->load([
'invitation.contractor:id,ulid,company_name,contact_person,email,specialization',
'invitation.package:id,ulid,title,evaluation_mode',
'invitation.package.criteria',
'scores.criteria',
'scores.evaluator:id,name',
'documents',
]);
return Inertia::render('BiddingManagement::Bids/Submission/Show', [
'submission' => $submission,
]);
}
public function shortlist(BidSubmission $submission)
{
try {
$submission->transitionTo(BidSubmissionStatus::Shortlisted);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Submission shortlisted.');
}
public function reject(BidSubmission $submission)
{
try {
$submission->transitionTo(BidSubmissionStatus::Rejected);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Submission rejected.');
}
public function myBids()
{
$user = Auth::user();
$contractor = Contractor::where('id', $user->contractor_id ?? null)->first();
abort_unless($contractor, 403, 'No contractor profile found for your account.');
$invitations = $contractor->bidInvitations()
->with([
'package:id,ulid,title,submission_deadline,status,evaluation_mode',
'package.project:id,name,code',
'submission:id,ulid,bid_invitation_id,submitted_price,completion_days,status,submitted_at',
])
->latest()
->get();
return Inertia::render('BiddingManagement::Bids/Contractor/MyBids', [
'invitations' => $invitations,
'contractor' => $contractor,
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Modules\BiddingManagement\Models;
use App\Models\User;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Enums\BidSubmissionStatus;
class BidAward extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'bid_package_id', 'bid_submission_id', 'awarded_by', 'awarded_at', 'notes',
];
protected function casts(): array
{
return [
'awarded_at' => 'datetime',
];
}
// --- Relationships ---
public function package(): BelongsTo
{
return $this->belongsTo(BidPackage::class, 'bid_package_id');
}
public function submission(): BelongsTo
{
return $this->belongsTo(BidSubmission::class, 'bid_submission_id');
}
public function awardedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'awarded_by');
}
// --- Observer logic (called from controller to keep it explicit) ---
public static function createAward(BidPackage $package, BidSubmission $winning, User $awarder, ?string $notes = null): self
{
// Mark package as awarded
$package->transitionTo(BidPackageStatus::Awarded);
// Mark winning submission as awarded
$winning->transitionTo(BidSubmissionStatus::Awarded);
// Reject all other non-rejected submissions
$package->submissions()
->whereNotIn('bid_submissions.id', [$winning->id])
->whereNotIn('bid_submissions.status', ['rejected'])
->get()
->each(fn ($sub) => $sub->update(['status' => BidSubmissionStatus::Rejected]));
// Link contractor to the project
$contractor = $winning->contractor;
$package->project()->update(['contractor_id' => $contractor->id]);
return self::create([
'bid_package_id' => $package->id,
'bid_submission_id' => $winning->id,
'awarded_by' => $awarder->id,
'awarded_at' => now(),
'notes' => $notes,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Modules\BiddingManagement\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BidCriteria extends Model
{
protected $table = 'bid_criteria';
protected $fillable = [
'bid_package_id', 'name', 'weight', 'description', 'sort_order',
];
protected function casts(): array
{
return [
'weight' => 'decimal:2',
'sort_order' => 'integer',
];
}
public function package(): BelongsTo
{
return $this->belongsTo(BidPackage::class, 'bid_package_id');
}
public function scores(): HasMany
{
return $this->hasMany(BidScore::class);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Modules\BiddingManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Modules\ContractorManagement\Models\Contractor;
class BidInvitation extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'bid_package_id', 'contractor_id', 'status',
'invited_at', 'responded_at', 'decline_reason',
];
protected function casts(): array
{
return [
'invited_at' => 'datetime',
'responded_at' => 'datetime',
];
}
public function package(): BelongsTo
{
return $this->belongsTo(BidPackage::class, 'bid_package_id');
}
public function contractor(): BelongsTo
{
return $this->belongsTo(Contractor::class);
}
public function submission(): HasOne
{
return $this->hasOne(BidSubmission::class);
}
public function accept(): void
{
$this->update([
'status' => 'accepted',
'responded_at' => now(),
]);
}
public function decline(string $reason = null): void
{
$this->update([
'status' => 'declined',
'responded_at' => now(),
'decline_reason' => $reason,
]);
}
public function getHasSubmittedAttribute(): bool
{
return $this->submission()->exists();
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Modules\BiddingManagement\Models;
use App\Models\User;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Enums\EvaluationMode;
use Modules\DocumentManagement\Models\Document;
use Modules\ProjectManagement\Models\Project;
class BidPackage extends Model
{
use HasPublicIdentifier, SoftDeletes;
protected $fillable = [
'project_id', 'created_by', 'title', 'description',
'status', 'evaluation_mode', 'submission_deadline',
'validity_period', 'instructions',
];
protected function casts(): array
{
return [
'status' => BidPackageStatus::class,
'evaluation_mode' => EvaluationMode::class,
'submission_deadline' => 'date',
'validity_period' => 'date',
];
}
// --- Relationships ---
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function criteria(): HasMany
{
return $this->hasMany(BidCriteria::class)->orderBy('sort_order');
}
public function invitations(): HasMany
{
return $this->hasMany(BidInvitation::class);
}
public function submissions(): HasManyThrough
{
return $this->hasManyThrough(BidSubmission::class, BidInvitation::class);
}
public function award(): HasOne
{
return $this->hasOne(BidAward::class);
}
public function documents(): MorphMany
{
return $this->morphMany(Document::class, 'documentable');
}
// --- State Machine ---
public function transitionTo(BidPackageStatus $newStatus): void
{
$current = $this->status;
if (!in_array($newStatus, $current->allowedTransitions())) {
throw new \InvalidArgumentException(
"Cannot transition from {$current->label()} to {$newStatus->label()}"
);
}
// Guard: must have at least one submission before evaluating
if ($newStatus === BidPackageStatus::Evaluating) {
if ($this->submissions()->count() === 0) {
throw new \InvalidArgumentException('Cannot start evaluation without any submissions.');
}
}
$this->update(['status' => $newStatus]);
}
// --- Computed Helpers ---
public function totalCriteriaWeight(): float
{
return (float) $this->criteria()->sum('weight');
}
public function isFullyScored(): bool
{
if ($this->evaluation_mode !== EvaluationMode::Scored) {
return true;
}
$criteriaCount = $this->criteria()->count();
if ($criteriaCount === 0) {
return false;
}
return $this->submissions()
->where('bid_submissions.status', '!=', 'rejected')
->get()
->every(fn ($sub) => $sub->scores()->count() >= $criteriaCount);
}
public function getIsDeadlinePassedAttribute(): bool
{
return $this->submission_deadline && $this->submission_deadline->isPast();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Modules\BiddingManagement\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BidScore extends Model
{
protected $fillable = [
'bid_submission_id', 'bid_criteria_id', 'evaluated_by', 'score', 'notes',
];
protected function casts(): array
{
return [
'score' => 'decimal:2',
];
}
public function submission(): BelongsTo
{
return $this->belongsTo(BidSubmission::class, 'bid_submission_id');
}
public function criteria(): BelongsTo
{
return $this->belongsTo(BidCriteria::class, 'bid_criteria_id');
}
public function evaluator(): BelongsTo
{
return $this->belongsTo(User::class, 'evaluated_by');
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Modules\BiddingManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Modules\BiddingManagement\Enums\BidSubmissionStatus;
use Modules\DocumentManagement\Models\Document;
class BidSubmission extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'bid_invitation_id', 'submitted_price', 'completion_days',
'validity_date', 'technical_notes', 'status', 'submitted_at',
];
protected function casts(): array
{
return [
'status' => BidSubmissionStatus::class,
'submitted_price' => 'decimal:2',
'completion_days' => 'integer',
'validity_date' => 'date',
'submitted_at' => 'datetime',
];
}
// --- Relationships ---
public function invitation(): BelongsTo
{
return $this->belongsTo(BidInvitation::class, 'bid_invitation_id');
}
public function scores(): HasMany
{
return $this->hasMany(BidScore::class);
}
public function documents(): MorphMany
{
return $this->morphMany(Document::class, 'documentable');
}
// --- State Machine ---
public function transitionTo(BidSubmissionStatus $newStatus): void
{
$current = $this->status;
if (!in_array($newStatus, $current->allowedTransitions())) {
throw new \InvalidArgumentException(
"Cannot transition from {$current->label()} to {$newStatus->label()}"
);
}
$this->update(['status' => $newStatus]);
}
// --- Computed ---
public function getComputedScoreAttribute(): float
{
$criteria = $this->invitation->package->criteria;
if ($criteria->isEmpty()) {
return 0.0;
}
$total = 0.0;
foreach ($this->scores as $score) {
$criterion = $criteria->firstWhere('id', $score->bid_criteria_id);
if ($criterion) {
$total += ($score->score / 100) * $criterion->weight;
}
}
return round($total, 2);
}
public function getContractorAttribute()
{
return $this->invitation->contractor;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Modules\BiddingManagement\Providers;
use Nwidart\Modules\Support\ModuleServiceProvider;
class BiddingManagementServiceProvider extends ModuleServiceProvider
{
protected string $name = 'BiddingManagement';
protected string $nameLower = 'biddingmanagement';
protected array $providers = [
EventServiceProvider::class,
RouteServiceProvider::class,
];
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Modules\BiddingManagement\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [];
protected static $shouldDiscoverEvents = true;
protected function configureEmailVerification(): void {}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Modules\BiddingManagement\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected string $name = 'BiddingManagement';
public function boot(): void
{
parent::boot();
}
public function map(): void
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
protected function mapWebRoutes(): void
{
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
}
protected function mapApiRoutes(): void
{
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
}
}

View File

@@ -0,0 +1,28 @@
{
"name": "nwidart/biddingmanagement",
"description": "Contractor bidding and tender management",
"authors": [
{
"name": "Nicolas Widart",
"email": "n.widart@gmail.com"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {}
}
},
"autoload": {
"psr-4": {
"Modules\\BiddingManagement\\": "app/",
"Modules\\BiddingManagement\\Database\\Factories\\": "database/factories/",
"Modules\\BiddingManagement\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Modules\\BiddingManagement\\Tests\\": "tests/"
}
}
}

View File

@@ -0,0 +1,101 @@
<?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
{
// Bid Packages — one per project tender
Schema::create('bid_packages', function (Blueprint $table) {
$table->id();
$table->string('ulid')->unique();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('draft'); // draft, open, evaluating, awarded, cancelled
$table->string('evaluation_mode')->default('simple'); // simple, scored
$table->date('submission_deadline')->nullable();
$table->date('validity_period')->nullable(); // bid price validity
$table->text('instructions')->nullable();
$table->timestamps();
$table->softDeletes();
});
// Scoring criteria for scored-mode packages
Schema::create('bid_criteria', function (Blueprint $table) {
$table->id();
$table->foreignId('bid_package_id')->constrained()->cascadeOnDelete();
$table->string('name'); // e.g., Price, Technical Capability, Experience
$table->decimal('weight', 5, 2); // percentage weight (0100)
$table->text('description')->nullable();
$table->unsignedTinyInteger('sort_order')->default(0);
$table->timestamps();
});
// Which contractors are invited to bid
Schema::create('bid_invitations', function (Blueprint $table) {
$table->id();
$table->string('ulid')->unique();
$table->foreignId('bid_package_id')->constrained()->cascadeOnDelete();
$table->foreignId('contractor_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('invited'); // invited, accepted, declined
$table->timestamp('invited_at')->nullable();
$table->timestamp('responded_at')->nullable();
$table->text('decline_reason')->nullable();
$table->timestamps();
$table->unique(['bid_package_id', 'contractor_id']);
});
// Contractor's submitted proposal
Schema::create('bid_submissions', function (Blueprint $table) {
$table->id();
$table->string('ulid')->unique();
$table->foreignId('bid_invitation_id')->constrained()->cascadeOnDelete();
$table->decimal('submitted_price', 18, 2);
$table->unsignedInteger('completion_days');
$table->date('validity_date')->nullable();
$table->text('technical_notes')->nullable();
$table->string('status')->default('submitted'); // submitted, shortlisted, rejected, awarded
$table->timestamp('submitted_at')->useCurrent();
$table->timestamps();
});
// Evaluator scores per criterion per submission (scored mode only)
Schema::create('bid_scores', function (Blueprint $table) {
$table->id();
$table->foreignId('bid_submission_id')->constrained()->cascadeOnDelete();
$table->foreignId('bid_criteria_id')->constrained('bid_criteria')->cascadeOnDelete();
$table->foreignId('evaluated_by')->constrained('users')->cascadeOnDelete();
$table->decimal('score', 5, 2); // 0100
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['bid_submission_id', 'bid_criteria_id']);
});
// The winning bid award
Schema::create('bid_awards', function (Blueprint $table) {
$table->id();
$table->string('ulid')->unique();
$table->foreignId('bid_package_id')->constrained()->cascadeOnDelete();
$table->foreignId('bid_submission_id')->constrained()->cascadeOnDelete();
$table->foreignId('awarded_by')->constrained('users')->cascadeOnDelete();
$table->timestamp('awarded_at')->useCurrent();
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('bid_awards');
Schema::dropIfExists('bid_scores');
Schema::dropIfExists('bid_submissions');
Schema::dropIfExists('bid_invitations');
Schema::dropIfExists('bid_criteria');
Schema::dropIfExists('bid_packages');
}
};

View File

@@ -0,0 +1,11 @@
{
"name": "BiddingManagement",
"alias": "biddingmanagement",
"description": "Contractor bidding and tender management for projects",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\BiddingManagement\\Providers\\BiddingManagementServiceProvider"
],
"files": []
}

View File

@@ -0,0 +1,15 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.5",
"sass": "^1.69.5",
"postcss": "^8.3.7",
"vite": "^4.0.0"
}
}

View File

@@ -0,0 +1,203 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { Gavel, Send, Trophy, CalendarClock, CheckCircle2 } from 'lucide-react';
import { PageProps } from '@/types';
interface Invitation {
id: number; ulid: string; status: string; invited_at: string;
package: {
id: number; ulid: string; title: string; status: string; evaluation_mode: string;
submission_deadline?: string;
project: { id: number; name: string; code: string } | null;
};
submission?: {
id: number; ulid: string; submitted_price: string; completion_days: number;
status: string; submitted_at: string;
};
}
interface Props extends PageProps {
invitations: Invitation[];
contractor: { id: number; company_name: string };
}
const fmt = (v: string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
const statusLabel = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const invStatusVariant = (s: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (s) {
case 'accepted': return 'default';
case 'declined': return 'destructive';
default: return 'outline';
}
};
const subStatusVariant = (s: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (s) {
case 'awarded': return 'default';
case 'shortlisted': return 'secondary';
case 'rejected': return 'destructive';
default: return 'outline';
}
};
const pkgStatusVariant = (s: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (s) {
case 'open': return 'default';
case 'evaluating': return 'secondary';
case 'awarded': return 'default';
case 'cancelled': return 'destructive';
default: return 'outline';
}
};
export default function MyBids({ invitations, contractor }: Props) {
const { flash } = usePage<PageProps>().props;
const pending = invitations.filter(i => i.status === 'invited' && !i.submission && i.package.status === 'open');
const submitted = invitations.filter(i => i.submission);
const other = invitations.filter(i => i.status === 'declined' || (i.package.status !== 'open' && !i.submission));
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">My Bids</h2>
<span className="text-sm text-gray-500"> {contractor.company_name}</span>
</div>
}
>
<Head title="My Bids" />
<div className="py-6">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 space-y-6">
{flash?.success && <div className="rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
{/* Action Required */}
{pending.length > 0 && (
<Card className="border-amber-200 bg-amber-50">
<CardHeader>
<CardTitle className="text-amber-800 flex items-center gap-2">
<CalendarClock className="h-5 w-5" />
Action Required ({pending.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{pending.map(inv => (
<div key={inv.id} className="flex items-center justify-between rounded-lg bg-white border border-amber-200 p-4">
<div>
<p className="font-semibold">{inv.package.title}</p>
<p className="text-sm text-gray-500">
{inv.package.project
? <>{inv.package.project.name} ({inv.package.project.code})</>
: <span className="italic">No project</span>}
</p>
{inv.package.submission_deadline && (
<p className="text-xs text-amber-700 mt-1">
Deadline: {new Date(inv.package.submission_deadline).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline"
onClick={() => { if (confirm('Decline this invitation?')) router.post(route('bid-invitations.decline', inv.ulid)); }}>
Decline
</Button>
<Link href={route('bid-submissions.create', inv.ulid)}>
<Button size="sm">
<Send className="mr-2 h-4 w-4" />Submit Bid
</Button>
</Link>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Submitted Bids */}
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><CheckCircle2 className="h-5 w-5 text-green-600" />Submitted Bids</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bid Package</TableHead>
<TableHead>Project</TableHead>
<TableHead>Package Status</TableHead>
<TableHead className="text-right">My Price</TableHead>
<TableHead>My Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submitted.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-gray-500 py-8">No submitted bids yet.</TableCell></TableRow>
) : submitted.map(inv => (
<TableRow key={inv.id} className={inv.submission?.status === 'awarded' ? 'bg-amber-50' : ''}>
<TableCell className="font-medium">
{inv.submission?.status === 'awarded' && <Trophy className="inline h-4 w-4 text-amber-500 mr-1" />}
{inv.package.title}
</TableCell>
<TableCell className="text-gray-500">{inv.package.project?.name ?? '—'}</TableCell>
<TableCell><Badge variant={pkgStatusVariant(inv.package.status)}>{statusLabel(inv.package.status)}</Badge></TableCell>
<TableCell className="text-right font-medium">
{inv.submission ? fmt(inv.submission.submitted_price) : '—'}
</TableCell>
<TableCell>
{inv.submission && (
<Badge variant={subStatusVariant(inv.submission.status)}>{statusLabel(inv.submission.status)}</Badge>
)}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{inv.submission ? new Date(inv.submission.submitted_at).toLocaleDateString() : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Declined / Missed */}
{other.length > 0 && (
<Card>
<CardHeader><CardTitle className="text-gray-500">Other Invitations</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bid Package</TableHead>
<TableHead>Project</TableHead>
<TableHead>My Response</TableHead>
<TableHead>Package Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{other.map(inv => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.package.title}</TableCell>
<TableCell className="text-gray-500">{inv.package.project?.name ?? '—'}</TableCell>
<TableCell><Badge variant={invStatusVariant(inv.status)}>{statusLabel(inv.status)}</Badge></TableCell>
<TableCell><Badge variant={pkgStatusVariant(inv.package.status)}>{statusLabel(inv.package.status)}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,238 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Textarea } from '@/Components/ui/textarea';
import { Badge } from '@/Components/ui/badge';
import { ArrowLeft, Send, Upload, X, FileText, CalendarClock } from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent, useRef, useState } from 'react';
interface Criteria { id: number; name: string; weight: string; description?: string }
interface Invitation {
id: number; ulid: string; status: string;
package: {
id: number; ulid: string; title: string; description?: string; instructions?: string;
submission_deadline?: string; evaluation_mode: string;
project: { id: number; name: string; code: string } | null;
criteria: Criteria[];
};
}
interface Props extends PageProps {
invitation: Invitation;
}
export default function Submit({ invitation }: Props) {
const pkg = invitation.package;
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, setData, post, transform, processing, errors } = useForm({
submitted_price: '',
completion_days: '',
validity_date: '',
technical_notes: '',
documents: [] as File[],
});
// Normalize empty date to null so Laravel's 'nullable|date' rule passes
transform((d) => ({ ...d, validity_date: d.validity_date || null }));
const handleFileAdd = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files ?? []);
const updated = [...files, ...selected];
setFiles(updated);
setData('documents', updated);
};
const removeFile = (i: number) => {
const updated = files.filter((_, idx) => idx !== i);
setFiles(updated);
setData('documents', updated);
};
const submit = (e: FormEvent) => {
e.preventDefault();
post(route('bid-submissions.store', invitation.ulid), {
forceFormData: true,
});
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-4">
<Link href={route('bids.my')}><Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button></Link>
<div>
<h2 className="text-xl font-semibold leading-tight text-gray-800">Submit Bid</h2>
<p className="text-sm text-gray-500">
{pkg.title}{pkg.project ? `${pkg.project.name} (${pkg.project.code})` : ''}
</p>
</div>
</div>
}
>
<Head title={`Submit Bid — ${pkg.title}`} />
<div className="py-6">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 space-y-6">
{/* Package Info */}
<Card>
<CardHeader><CardTitle>Bid Package Details</CardTitle></CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center gap-4">
{pkg.submission_deadline && (
<div className="flex items-center gap-1 text-amber-700">
<CalendarClock className="h-4 w-4" />
<span>Deadline: {new Date(pkg.submission_deadline).toLocaleDateString()}</span>
</div>
)}
<Badge variant="outline">{pkg.evaluation_mode === 'scored' ? 'Scored Evaluation' : 'Simple Selection'}</Badge>
</div>
{pkg.description && <p className="text-gray-600">{pkg.description}</p>}
{pkg.instructions && (
<div className="rounded-md bg-blue-50 border border-blue-200 p-3">
<p className="text-xs font-medium text-blue-800 mb-1">Instructions for Bidders</p>
<p className="text-sm text-blue-700 whitespace-pre-wrap">{pkg.instructions}</p>
</div>
)}
{pkg.evaluation_mode === 'scored' && pkg.criteria.length > 0 && (
<div>
<p className="text-xs text-gray-500 mb-2 font-medium">Your submission will be scored on:</p>
<div className="space-y-1">
{pkg.criteria.map(c => (
<div key={c.id} className="flex items-center justify-between text-xs bg-gray-50 rounded px-3 py-1.5">
<span>{c.name}</span>
<Badge variant="outline">{parseFloat(c.weight)}%</Badge>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Submission Form */}
<form onSubmit={submit} className="space-y-6">
<Card>
<CardHeader><CardTitle>Your Proposal</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Bid Price (PHP) *</Label>
<Input
type="number"
step="0.01"
min="0"
value={data.submitted_price}
onChange={e => setData('submitted_price', e.target.value)}
placeholder="0.00"
className={errors.submitted_price ? 'border-red-500' : ''}
/>
{errors.submitted_price && <p className="mt-1 text-xs text-red-500">{errors.submitted_price}</p>}
</div>
<div>
<Label>Completion (Days) *</Label>
<Input
type="number"
min="1"
value={data.completion_days}
onChange={e => setData('completion_days', e.target.value)}
placeholder="e.g., 90"
className={errors.completion_days ? 'border-red-500' : ''}
/>
{errors.completion_days && <p className="mt-1 text-xs text-red-500">{errors.completion_days}</p>}
</div>
</div>
<div>
<Label>Bid Validity Date</Label>
<Input
type="date"
value={data.validity_date}
onChange={e => setData('validity_date', e.target.value)}
className={errors.validity_date ? 'border-red-500' : ''}
/>
{errors.validity_date
? <p className="mt-1 text-xs text-red-500">{errors.validity_date}</p>
: <p className="mt-1 text-xs text-gray-400">The date until which your quoted price is valid.</p>
}
</div>
<div>
<Label>Technical Notes / Methodology</Label>
<Textarea
value={data.technical_notes}
onChange={e => setData('technical_notes', e.target.value)}
rows={5}
placeholder="Describe your approach, methodology, key team members, experience with similar projects..."
/>
</div>
</CardContent>
</Card>
{/* Document Attachments */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Supporting Documents</CardTitle>
<Button type="button" size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />Attach Files
</Button>
</div>
</CardHeader>
<CardContent>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
onChange={handleFileAdd}
className="hidden"
/>
{files.length === 0 ? (
<div
className="rounded-lg border-2 border-dashed border-gray-200 p-8 text-center cursor-pointer hover:border-gray-300 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500">Click to attach BOQ, technical proposal, or other supporting documents</p>
<p className="text-xs text-gray-400 mt-1">PDF, Word, Excel, Images max 20MB per file</p>
</div>
) : (
<div className="space-y-2">
{files.map((file, i) => (
<div key={i} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-gray-400" />
<span className="font-medium">{file.name}</span>
<span className="text-gray-400">({(file.size / 1024).toFixed(0)} KB)</span>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeFile(i)}>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} className="w-full mt-2">
<Upload className="mr-2 h-4 w-4" />Add More Files
</Button>
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Link href={route('bids.my')}><Button variant="outline" type="button">Cancel</Button></Link>
<Button type="submit" disabled={processing}>
<Send className="mr-2 h-4 w-4" />Submit Bid
</Button>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,10 @@
import Form from './Form';
import { PageProps } from '@/types';
interface Props extends PageProps {
projects: { id: number; ulid: string; name: string; code: string }[];
}
export default function Create({ projects }: Props) {
return <Form projects={projects} />;
}

View File

@@ -0,0 +1,11 @@
import Form from './Form';
import { PageProps } from '@/types';
interface Props extends PageProps {
projects: { id: number; ulid: string; name: string; code: string }[];
package: Parameters<typeof Form>[0]['package'];
}
export default function Edit({ projects, package: pkg }: Props) {
return <Form projects={projects} package={pkg} />;
}

View File

@@ -0,0 +1,251 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Textarea } from '@/Components/ui/textarea';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import { Badge } from '@/Components/ui/badge';
import { ArrowLeft, Plus, Trash2, Gavel, AlertTriangle } from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent, useState } from 'react';
interface Project { id: number; ulid: string; name: string; code: string }
interface CriterionRow { name: string; weight: string; description: string }
interface Props {
projects: Project[];
package?: {
id: number; ulid: string; title: string; description?: string; evaluation_mode: string;
submission_deadline?: string; validity_period?: string; instructions?: string;
project: Project;
criteria: { id: number; name: string; weight: string; description?: string }[];
};
}
export default function Form({ projects, package: pkg }: Props) {
const isEdit = !!pkg;
const { data, setData, post, put, processing, errors } = useForm({
project_id: pkg?.project.ulid ?? '',
title: pkg?.title ?? '',
description: pkg?.description ?? '',
evaluation_mode: pkg?.evaluation_mode ?? 'simple',
submission_deadline: pkg?.submission_deadline ?? '',
validity_period: pkg?.validity_period ?? '',
instructions: pkg?.instructions ?? '',
criteria: (pkg?.criteria ?? []).map(c => ({
name: c.name, weight: c.weight, description: c.description ?? '',
})) as CriterionRow[],
});
const totalWeight = data.criteria.reduce((sum, c) => sum + (parseFloat(c.weight) || 0), 0);
const weightValid = data.evaluation_mode !== 'scored' || (totalWeight > 0 && totalWeight <= 100);
const addCriterion = () => setData('criteria', [...data.criteria, { name: '', weight: '', description: '' }]);
const updateCriterion = (i: number, field: keyof CriterionRow, value: string) => {
const updated = [...data.criteria];
updated[i] = { ...updated[i], [field]: value };
setData('criteria', updated);
};
const removeCriterion = (i: number) => {
setData('criteria', data.criteria.filter((_, idx) => idx !== i));
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (isEdit) {
put(route('bids.update', pkg!.ulid));
} else {
post(route('bids.store'));
}
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-4">
<Link href={route('bids.index')}><Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button></Link>
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">
{isEdit ? 'Edit Bid Package' : 'New Bid Package'}
</h2>
</div>
</div>
}
>
<Head title={isEdit ? 'Edit Bid Package' : 'New Bid Package'} />
<div className="py-6">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<form onSubmit={submit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader><CardTitle>Bid Package Details</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Project *</Label>
<Select value={data.project_id} onValueChange={(v) => { if (v) setData('project_id', v); }}>
<SelectTrigger className={errors.project_id ? 'border-red-500' : ''}>
<SelectValue placeholder="Select a project">
{data.project_id
? (() => {
const p = projects.find(p => p.ulid === data.project_id);
return p ? `${p.name} (${p.code})` : data.project_id;
})()
: null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{projects.map(p => (
<SelectItem key={p.id} value={p.ulid}>{p.name} ({p.code})</SelectItem>
))}
</SelectContent>
</Select>
{errors.project_id && <p className="mt-1 text-xs text-red-500">{errors.project_id}</p>}
</div>
<div>
<Label>Title *</Label>
<Input value={data.title} onChange={e => setData('title', e.target.value)} className={errors.title ? 'border-red-500' : ''} placeholder="e.g., Civil Works Package A" />
{errors.title && <p className="mt-1 text-xs text-red-500">{errors.title}</p>}
</div>
<div>
<Label>Description</Label>
<Textarea value={data.description} onChange={e => setData('description', e.target.value)} rows={3} placeholder="Scope of work, requirements..." />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Submission Deadline</Label>
<Input type="date" value={data.submission_deadline} onChange={e => setData('submission_deadline', e.target.value)} />
</div>
<div>
<Label>Bid Validity Until</Label>
<Input type="date" value={data.validity_period} onChange={e => setData('validity_period', e.target.value)} />
</div>
</div>
<div>
<Label>Instructions for Bidders</Label>
<Textarea value={data.instructions} onChange={e => setData('instructions', e.target.value)} rows={3} placeholder="Special requirements, submission format..." />
</div>
</CardContent>
</Card>
{/* Evaluation Mode */}
<Card>
<CardHeader><CardTitle>Evaluation Mode</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{(['simple', 'scored'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setData('evaluation_mode', mode)}
className={`rounded-lg border-2 p-4 text-left transition-colors ${data.evaluation_mode === mode ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-gray-300'}`}
>
<div className="font-medium">{mode === 'simple' ? 'Simple Selection' : 'Weighted Scorecard'}</div>
<p className="mt-1 text-xs text-gray-500">
{mode === 'simple'
? 'Manually choose the winner after reviewing all submissions.'
: 'Score each submission against weighted criteria. Winner is determined by highest score.'}
</p>
</button>
))}
</div>
{/* Criteria builder (scored mode only) */}
{data.evaluation_mode === 'scored' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Evaluation Criteria</Label>
<div className="flex items-center gap-3">
{data.criteria.length > 0 && (
<Badge variant={weightValid ? 'default' : 'destructive'}>
Total: {totalWeight.toFixed(1)}%
</Badge>
)}
<Button type="button" size="sm" variant="outline" onClick={addCriterion}>
<Plus className="mr-1 h-3 w-3" /> Add Criterion
</Button>
</div>
</div>
{!weightValid && totalWeight > 100 && (
<div className="flex items-center gap-2 rounded-md bg-red-50 p-2 text-xs text-red-700">
<AlertTriangle className="h-4 w-4" />
Total weight exceeds 100%. Please adjust the weights.
</div>
)}
{data.criteria.length === 0 && (
<p className="text-center text-sm text-gray-400 py-4">
No criteria yet. Add at least one to use scored evaluation.
</p>
)}
{data.criteria.map((c, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border p-3">
<div className="flex-1 grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label className="text-xs">Criterion Name *</Label>
<Input
value={c.name}
onChange={e => updateCriterion(i, 'name', e.target.value)}
placeholder="e.g., Price, Technical Capability"
className="mt-1 text-sm"
/>
</div>
<div>
<Label className="text-xs">Weight (%)*</Label>
<Input
type="number"
min="1"
max="100"
value={c.weight}
onChange={e => updateCriterion(i, 'weight', e.target.value)}
placeholder="e.g., 50"
className="mt-1 text-sm"
/>
</div>
<div className="col-span-3">
<Label className="text-xs">Description (optional)</Label>
<Input
value={c.description}
onChange={e => updateCriterion(i, 'description', e.target.value)}
placeholder="How this criterion is scored..."
className="mt-1 text-sm"
/>
</div>
</div>
<Button type="button" variant="ghost" size="icon-sm" className="mt-5" onClick={() => removeCriterion(i)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Link href={route('bids.index')}><Button variant="outline" type="button">Cancel</Button></Link>
<Button type="submit" disabled={processing || (data.evaluation_mode === 'scored' && !weightValid && data.criteria.length > 0)}>
{isEdit ? 'Save Changes' : 'Create Bid Package'}
</Button>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,182 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent } from '@/Components/ui/card';
import { DataTableToolbar } from '@/Components/DataTableToolbar';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { PaginatedData, PageProps } from '@/types';
import { Plus, Eye, Gavel, CalendarClock, Users } from 'lucide-react';
import { FormEvent, useState } from 'react';
interface PackageItem {
id: number; ulid: string; title: string; status: string; evaluation_mode: string;
submission_deadline?: string;
invitations_count: number; submissions_count: number;
project: { id: number; ulid: string; name: string; code: string } | null;
creator: { id: number; name: string } | null;
}
interface Props extends PageProps {
packages: PaginatedData<PackageItem>;
filters: { search?: string; status?: string; evaluation_mode?: string; project?: string };
projects: { id: number; ulid: string; name: string; code: string }[];
}
const statusVariant = (s: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (s) {
case 'open': return 'default';
case 'evaluating': return 'secondary';
case 'awarded': return 'default';
case 'cancelled': return 'destructive';
default: return 'outline';
}
};
const statusLabel = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export default function Index({ packages, filters, projects }: Props) {
const { flash } = usePage<PageProps>().props;
const [search, setSearch] = useState(filters.search || '');
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
const [modeFilter, setModeFilter] = useState(filters.evaluation_mode || 'all');
const applyFilters = (e?: FormEvent) => {
e?.preventDefault();
router.get(route('bids.index'), {
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
evaluation_mode: modeFilter !== 'all' ? modeFilter : undefined,
}, { preserveState: true, replace: true });
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">Bid Packages</h2>
</div>
}
>
<Head title="Bid Packages" />
<div className="py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
<Card>
<DataTableToolbar
searchValue={search}
searchPlaceholder="Search bid packages..."
onSearchChange={setSearch}
onSearchSubmit={applyFilters}
filters={
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={(v) => { if (v) setStatusFilter(v); }}>
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="evaluating">Evaluating</SelectItem>
<SelectItem value="awarded">Awarded</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={modeFilter} onValueChange={(v) => { if (v) setModeFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Mode" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Modes</SelectItem>
<SelectItem value="simple">Simple Selection</SelectItem>
<SelectItem value="scored">Weighted Scorecard</SelectItem>
</SelectContent>
</Select>
</div>
}
actions={
<Link href={route('bids.create')}>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />New Bid Package</Button>
</Link>
}
/>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Project</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Deadline</TableHead>
<TableHead className="text-center">Invited</TableHead>
<TableHead className="text-center">Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{packages.data.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-gray-500 py-8">No bid packages found.</TableCell></TableRow>
) : (
packages.data.map((pkg) => (
<TableRow key={pkg.id}>
<TableCell className="font-medium">{pkg.title}</TableCell>
<TableCell className="text-gray-500">
{pkg.project
? <>{pkg.project.name} <span className="text-xs text-gray-400">({pkg.project.code})</span></>
: <span className="text-gray-400 italic">No project</span>}
</TableCell>
<TableCell>
<Badge variant={pkg.evaluation_mode === 'scored' ? 'default' : 'outline'}>
{pkg.evaluation_mode === 'scored' ? 'Scored' : 'Simple'}
</Badge>
</TableCell>
<TableCell>
{pkg.submission_deadline ? (
<div className="flex items-center gap-1 text-sm">
<CalendarClock className="h-3 w-3 text-gray-400" />
{new Date(pkg.submission_deadline).toLocaleDateString()}
</div>
) : <span className="text-gray-400"></span>}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Users className="h-3 w-3 text-gray-400" />
{pkg.invitations_count}
</div>
</TableCell>
<TableCell className="text-center">{pkg.submissions_count}</TableCell>
<TableCell><Badge variant={statusVariant(pkg.status)}>{statusLabel(pkg.status)}</Badge></TableCell>
<TableCell className="text-right">
<Link href={route('bids.show', pkg.ulid)}>
<Button variant="ghost" size="icon-sm" title="View"><Eye className="h-4 w-4" /></Button>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{packages.last_page > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600">Showing {packages.from} to {packages.to} of {packages.total}</p>
<div className="flex gap-1">
{packages.prev_page_url && <Link href={packages.prev_page_url}><Button variant="outline" size="sm">Previous</Button></Link>}
{packages.next_page_url && <Link href={packages.next_page_url}><Button variant="outline" size="sm">Next</Button></Link>}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,599 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Textarea } from '@/Components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from '@/Components/ui/dialog';
import {
ArrowLeft, Pencil, Plus, Trash2, Gavel, Users, FileText,
CheckCircle2, XCircle, Trophy, Star, ChevronUp, ChevronDown,
Send, Ban, PlayCircle,
} from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent, useMemo, useState } from 'react';
interface Criteria { id: number; name: string; weight: string; description?: string }
interface Score { id: number; bid_criteria_id: number; score: string; notes?: string; evaluator: { name: string } }
interface Document { id: number; ulid: string; title: string; current_file_name: string; current_file_path: string; mime_type: string }
interface Submission {
id: number; ulid: string; submitted_price: string; completion_days: number;
validity_date?: string; technical_notes?: string; status: string; submitted_at: string;
scores: Score[]; documents: Document[];
computed_score?: number;
}
interface Invitation {
id: number; ulid: string; status: string; invited_at: string; responded_at?: string;
decline_reason?: string;
contractor: { id: number; ulid: string; company_name: string; specialization?: string; rating: string };
submission?: Submission;
}
interface Award {
id: number; ulid: string; awarded_at: string; notes?: string;
submission: { bid_invitation_id: number };
awarded_by: { name: string };
}
interface Package {
id: number; ulid: string; title: string; description?: string; status: string;
evaluation_mode: string; submission_deadline?: string; validity_period?: string;
instructions?: string;
project: { id: number; ulid: string; name: string; code: string } | null;
creator: { id: number; name: string } | null;
criteria: Criteria[];
invitations: Invitation[];
award?: Award;
documents: Document[];
}
interface Props extends PageProps {
package: Package;
contractors: { id: number; ulid: string; company_name: string; specialization?: string; rating: string }[];
}
const fmt = (v: string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
const statusLabel = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const statusVariant = (s: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (s) {
case 'open': return 'default';
case 'evaluating': return 'secondary';
case 'awarded': return 'default';
case 'cancelled': return 'destructive';
default: return 'outline';
}
};
const submissionVariant = (s: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (s) {
case 'shortlisted': return 'default';
case 'awarded': return 'default';
case 'rejected': return 'destructive';
default: return 'outline';
}
};
export default function Show({ package: pkg, contractors }: Props) {
const { flash } = usePage<PageProps>().props;
const [inviteDialog, setInviteDialog] = useState(false);
const [awardDialog, setAwardDialog] = useState(false);
const [scoringSubmission, setScoringSubmission] = useState<Submission | null>(null);
const inviteForm = useForm({ contractor_ids: [] as string[] });
const awardForm = useForm({ submission_id: '', notes: '' });
const [scoreInputs, setScoreInputs] = useState<Record<number, { score: string; notes: string }>>({});
const uninvitedContractors = useMemo(() => {
const invitedIds = new Set(pkg.invitations.map(i => i.contractor.ulid));
return contractors.filter(c => !invitedIds.has(c.ulid));
}, [contractors, pkg.invitations]);
const [selectedContractors, setSelectedContractors] = useState<string[]>([]);
const toggleContractor = (ulid: string) => {
setSelectedContractors(prev =>
prev.includes(ulid) ? prev.filter(u => u !== ulid) : [...prev, ulid]
);
};
const submitInvite = (e: FormEvent) => {
e.preventDefault();
inviteForm.setData('contractor_ids', selectedContractors);
inviteForm.post(route('bid-invitations.store', pkg.ulid), {
onSuccess: () => { setInviteDialog(false); setSelectedContractors([]); },
});
};
const submitAward = (e: FormEvent) => {
e.preventDefault();
awardForm.post(route('bids.award', pkg.ulid), {
onSuccess: () => setAwardDialog(false),
});
};
const initScores = (submission: Submission) => {
const initial: Record<number, { score: string; notes: string }> = {};
pkg.criteria.forEach(c => {
const existing = submission.scores.find(s => s.bid_criteria_id === c.id);
initial[c.id] = { score: existing?.score ?? '', notes: existing?.notes ?? '' };
});
setScoreInputs(initial);
setScoringSubmission(submission);
};
const saveScores = (submission: Submission) => {
const scores = Object.entries(scoreInputs).map(([criteriaId, vals]) => ({
criteria_id: parseInt(criteriaId),
score: vals.score,
notes: vals.notes,
}));
router.post(route('bid-scores.store', submission.ulid), { scores }, {
onSuccess: () => setScoringSubmission(null),
});
};
const canPublish = pkg.status === 'draft';
const canEvaluate = pkg.status === 'open' && pkg.invitations.some(i => i.submission);
const canAward = ['open', 'evaluating'].includes(pkg.status);
const canCancel = !['awarded', 'cancelled'].includes(pkg.status);
const isScored = pkg.evaluation_mode === 'scored';
const submissionsWithScore = useMemo(() => {
return pkg.invitations
.filter(i => i.submission)
.map(i => {
const sub = i.submission!;
let computedScore = 0;
if (isScored && pkg.criteria.length > 0) {
pkg.criteria.forEach(c => {
const scoreEntry = sub.scores.find(s => s.bid_criteria_id === c.id);
if (scoreEntry) computedScore += (parseFloat(scoreEntry.score) / 100) * parseFloat(c.weight);
});
}
return { ...i, submission: { ...sub, computedScore: Math.round(computedScore * 100) / 100 } };
})
.sort((a, b) => b.submission.computedScore - a.submission.computedScore);
}, [pkg.invitations, pkg.criteria]);
return (
<AuthenticatedLayout
header={
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={route('bids.index')}><Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button></Link>
<div>
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">{pkg.title}</h2>
<Badge variant={statusVariant(pkg.status)}>{statusLabel(pkg.status)}</Badge>
<Badge variant="outline">{isScored ? 'Scored' : 'Simple'}</Badge>
</div>
<p className="text-sm text-gray-500">
{pkg.project
? <>{pkg.project.name} ({pkg.project.code})</>
: <span className="italic">No project assigned</span>}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{canPublish && (
<Link href={route('bids.edit', pkg.ulid)}>
<Button variant="outline" size="sm"><Pencil className="mr-2 h-4 w-4" />Edit</Button>
</Link>
)}
{canPublish && (
<Button size="sm" onClick={() => router.post(route('bids.publish', pkg.ulid))}>
<Send className="mr-2 h-4 w-4" />Publish
</Button>
)}
{canEvaluate && (
<Button size="sm" variant="secondary" onClick={() => router.post(route('bids.evaluate', pkg.ulid))}>
<PlayCircle className="mr-2 h-4 w-4" />Start Evaluation
</Button>
)}
{canCancel && (
<Button size="sm" variant="ghost" onClick={() => { if (confirm('Cancel this bid package?')) router.post(route('bids.cancel', pkg.ulid)); }}>
<Ban className="mr-2 h-4 w-4 text-red-500" />Cancel
</Button>
)}
</div>
</div>
}
>
<Head title={pkg.title} />
<div className="py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{flash?.success && <div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
{/* Award banner */}
{pkg.award && (
<div className="mb-4 flex items-center gap-3 rounded-lg bg-amber-50 border border-amber-200 p-4">
<Trophy className="h-5 w-5 text-amber-600" />
<div>
<p className="font-medium text-amber-800">Contract Awarded</p>
<p className="text-sm text-amber-700">
Awarded by {pkg.award.awarded_by.name} on {new Date(pkg.award.awarded_at).toLocaleDateString()}
{pkg.award.notes && <span> {pkg.award.notes}</span>}
</p>
</div>
</div>
)}
<Tabs defaultValue="invitations">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="invitations">
Invitations ({pkg.invitations.length})
</TabsTrigger>
<TabsTrigger value="submissions">
Submissions ({pkg.invitations.filter(i => i.submission).length})
</TabsTrigger>
{isScored && <TabsTrigger value="evaluation">Evaluation</TabsTrigger>}
{canAward && <TabsTrigger value="award">Award</TabsTrigger>}
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<Card><CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div><p className="text-xs text-gray-500">Created By</p><p className="font-medium">{pkg.creator?.name ?? '—'}</p></div>
<div><p className="text-xs text-gray-500">Deadline</p><p className="font-medium">{pkg.submission_deadline ? new Date(pkg.submission_deadline).toLocaleDateString() : '—'}</p></div>
{pkg.description && <div className="col-span-2"><p className="text-xs text-gray-500">Description</p><p>{pkg.description}</p></div>}
{pkg.instructions && <div className="col-span-2"><p className="text-xs text-gray-500">Instructions for Bidders</p><p className="whitespace-pre-wrap">{pkg.instructions}</p></div>}
{isScored && pkg.criteria.length > 0 && (
<div className="col-span-2">
<p className="text-xs text-gray-500 mb-2">Scoring Criteria</p>
<div className="space-y-1">
{pkg.criteria.map(c => (
<div key={c.id} className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 text-sm">
<span>{c.name}</span>
<Badge variant="outline">{parseFloat(c.weight)}%</Badge>
</div>
))}
</div>
</div>
)}
</div>
</CardContent></Card>
</TabsContent>
{/* Invitations Tab */}
<TabsContent value="invitations">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Invited Contractors</CardTitle>
{['draft', 'open'].includes(pkg.status) && (
<Dialog open={inviteDialog} onOpenChange={setInviteDialog}>
<DialogTrigger render={<Button size="sm" />}><Plus className="mr-2 h-4 w-4" />Invite Contractors</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>Invite Contractors</DialogTitle></DialogHeader>
<form onSubmit={submitInvite} className="space-y-4">
<div className="max-h-60 overflow-y-auto space-y-2">
{uninvitedContractors.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">All active contractors are already invited.</p>
) : uninvitedContractors.map(c => (
<label key={c.ulid} className={`flex items-center gap-3 rounded-md border p-3 cursor-pointer transition-colors ${selectedContractors.includes(c.ulid) ? 'border-primary bg-primary/5' : 'hover:bg-gray-50'}`}>
<input type="checkbox" checked={selectedContractors.includes(c.ulid)} onChange={() => toggleContractor(c.ulid)} className="rounded" />
<div>
<p className="font-medium text-sm">{c.company_name}</p>
{c.specialization && <p className="text-xs text-gray-500">{c.specialization}</p>}
</div>
</label>
))}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setInviteDialog(false)}>Cancel</Button>
<Button type="submit" disabled={selectedContractors.length === 0 || inviteForm.processing}>
<Users className="mr-2 h-4 w-4" />Invite ({selectedContractors.length})
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Contractor</TableHead>
<TableHead>Specialization</TableHead>
<TableHead>Response</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkg.invitations.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-gray-500 py-8">No contractors invited yet.</TableCell></TableRow>
) : pkg.invitations.map(inv => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.contractor.company_name}</TableCell>
<TableCell><Badge variant="outline">{inv.contractor.specialization || '—'}</Badge></TableCell>
<TableCell>
<Badge variant={inv.status === 'accepted' ? 'default' : inv.status === 'declined' ? 'destructive' : 'outline'}>
{statusLabel(inv.status)}
</Badge>
</TableCell>
<TableCell>
{inv.submission ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <XCircle className="h-4 w-4 text-gray-300" />}
</TableCell>
<TableCell className="text-right">
{!inv.submission && ['draft', 'open'].includes(pkg.status) && (
<Button variant="ghost" size="icon-sm" title="Remove invitation"
onClick={() => { if (confirm(`Remove ${inv.contractor.company_name}?`)) router.delete(route('bid-invitations.destroy', inv.ulid)); }}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Submissions Tab */}
<TabsContent value="submissions">
<div className="space-y-4">
{submissionsWithScore.length === 0 ? (
<Card><CardContent className="py-8 text-center text-gray-500">No submissions received yet.</CardContent></Card>
) : submissionsWithScore.map((inv, rank) => (
<Card key={inv.id} className={inv.submission.status === 'awarded' ? 'ring-2 ring-amber-400' : ''}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{isScored && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-sm font-bold text-gray-600">
#{rank + 1}
</div>
)}
<div>
<div className="flex items-center gap-2">
<span className="font-semibold">{inv.contractor.company_name}</span>
{inv.submission.status === 'awarded' && <Trophy className="h-4 w-4 text-amber-500" />}
</div>
<p className="text-xs text-gray-500">Submitted {new Date(inv.submission.submitted_at).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2">
{isScored && (
<div className="text-right">
<p className="text-xs text-gray-500">Score</p>
<p className="font-bold text-lg">{inv.submission.computedScore.toFixed(1)}%</p>
</div>
)}
<Badge variant={submissionVariant(inv.submission.status)}>{statusLabel(inv.submission.status)}</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 text-sm mb-4">
<div><p className="text-xs text-gray-500">Price</p><p className="font-semibold">{fmt(inv.submission.submitted_price)}</p></div>
<div><p className="text-xs text-gray-500">Completion</p><p className="font-semibold">{inv.submission.completion_days} days</p></div>
<div><p className="text-xs text-gray-500">Valid Until</p><p>{inv.submission.validity_date ? new Date(inv.submission.validity_date).toLocaleDateString() : '—'}</p></div>
</div>
{inv.submission.technical_notes && (
<p className="text-sm text-gray-600 mb-4">{inv.submission.technical_notes}</p>
)}
{inv.submission.documents.length > 0 && (
<div className="mb-4">
<p className="text-xs text-gray-500 mb-1">Attached Documents</p>
<div className="flex flex-wrap gap-2">
{inv.submission.documents.map(doc => (
<Badge key={doc.id} variant="outline" className="flex items-center gap-1">
<FileText className="h-3 w-3" />{doc.title}
</Badge>
))}
</div>
</div>
)}
{['evaluating', 'open'].includes(pkg.status) && inv.submission.status === 'submitted' && (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => router.post(route('bid-submissions.shortlist', inv.submission!.ulid))}>
<ChevronUp className="mr-1 h-4 w-4 text-green-500" />Shortlist
</Button>
<Button size="sm" variant="outline" onClick={() => router.post(route('bid-submissions.reject', inv.submission!.ulid))}>
<ChevronDown className="mr-1 h-4 w-4 text-red-500" />Reject
</Button>
{isScored && (
<Button size="sm" variant="secondary" onClick={() => initScores(inv.submission!)}>
<Star className="mr-1 h-4 w-4" />Score
</Button>
)}
</div>
)}
{['evaluating', 'open'].includes(pkg.status) && inv.submission.status === 'shortlisted' && isScored && (
<Button size="sm" variant="secondary" onClick={() => initScores(inv.submission!)}>
<Star className="mr-1 h-4 w-4" />Update Scores
</Button>
)}
</CardContent>
</Card>
))}
</div>
</TabsContent>
{/* Evaluation Tab (scored mode) */}
{isScored && (
<TabsContent value="evaluation">
<Card>
<CardHeader><CardTitle>Scoring Matrix</CardTitle></CardHeader>
<CardContent>
{pkg.criteria.length === 0 ? (
<p className="text-center text-gray-500 py-8">No criteria defined for this package.</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Contractor</TableHead>
<TableHead>Bid Price</TableHead>
{pkg.criteria.map(c => (
<TableHead key={c.id} className="text-center min-w-[100px]">
{c.name}
<span className="ml-1 text-xs text-gray-400">({parseFloat(c.weight)}%)</span>
</TableHead>
))}
<TableHead className="text-center font-bold">Total Score</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissionsWithScore.map((inv, rank) => (
<TableRow key={inv.id} className={rank === 0 ? 'bg-amber-50' : ''}>
<TableCell className="font-medium">
{rank === 0 && <Trophy className="inline h-4 w-4 text-amber-500 mr-1" />}
{inv.contractor.company_name}
</TableCell>
<TableCell>{fmt(inv.submission.submitted_price)}</TableCell>
{pkg.criteria.map(c => {
const score = inv.submission.scores.find(s => s.bid_criteria_id === c.id);
return (
<TableCell key={c.id} className="text-center">
{score ? (
<span className={`font-medium ${parseFloat(score.score) >= 70 ? 'text-green-600' : parseFloat(score.score) >= 40 ? 'text-amber-600' : 'text-red-600'}`}>
{parseFloat(score.score).toFixed(0)}
</span>
) : <span className="text-gray-300"></span>}
</TableCell>
);
})}
<TableCell className="text-center font-bold">{inv.submission.computedScore.toFixed(1)}%</TableCell>
<TableCell><Badge variant={submissionVariant(inv.submission.status)}>{statusLabel(inv.submission.status)}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
)}
{/* Award Tab */}
{canAward && (
<TabsContent value="award">
<Card>
<CardHeader><CardTitle>Award Contract</CardTitle></CardHeader>
<CardContent>
<form onSubmit={submitAward} className="space-y-4">
<div>
<Label>Select Winning Submission *</Label>
<Select value={awardForm.data.submission_id} onValueChange={v => { if (v) awardForm.setData('submission_id', v); }}>
<SelectTrigger>
<SelectValue placeholder="Choose the winning bid...">
{awardForm.data.submission_id && (() => {
const sel = submissionsWithScore.find(i => i.submission.ulid === awardForm.data.submission_id);
if (!sel) return null;
return `${sel.contractor.company_name}${fmt(sel.submission.submitted_price)}${isScored ? ` (Score: ${sel.submission.computedScore.toFixed(1)}%)` : ''}`;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{submissionsWithScore
.filter(i => i.submission.status !== 'rejected')
.map(inv => {
const label = `${inv.contractor.company_name}${fmt(inv.submission.submitted_price)}${isScored ? ` (Score: ${inv.submission.computedScore.toFixed(1)}%)` : ''}`;
return (
<SelectItem
key={inv.submission.ulid}
value={inv.submission.ulid}
>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label>Notes (optional)</Label>
<Textarea
value={awardForm.data.notes}
onChange={e => awardForm.setData('notes', e.target.value)}
placeholder="Rationale for selection, special conditions..."
rows={3}
/>
</div>
<div className="rounded-md bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800">
<strong>Note:</strong> Awarding this contract will automatically assign the winning contractor to the project and close the bid package.
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={!awardForm.data.submission_id || awardForm.processing}
className="bg-amber-600 hover:bg-amber-700"
>
<Trophy className="mr-2 h-4 w-4" />Award Contract
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
{/* Inline Scoring Dialog */}
{scoringSubmission && (
<Dialog open={!!scoringSubmission} onOpenChange={() => setScoringSubmission(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Score Submission</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{pkg.criteria.map(c => (
<div key={c.id} className="space-y-2">
<div className="flex items-center justify-between">
<Label>{c.name} <span className="text-xs text-gray-400">({parseFloat(c.weight)}% weight)</span></Label>
</div>
<Input
type="number"
min="0"
max="100"
placeholder="Score 0100"
value={scoreInputs[c.id]?.score ?? ''}
onChange={e => setScoreInputs(prev => ({
...prev,
[c.id]: { ...prev[c.id], score: e.target.value }
}))}
/>
<Input
placeholder="Notes (optional)"
value={scoreInputs[c.id]?.notes ?? ''}
onChange={e => setScoreInputs(prev => ({
...prev,
[c.id]: { ...prev[c.id], notes: e.target.value }
}))}
/>
</div>
))}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setScoringSubmission(null)}>Cancel</Button>
<Button onClick={() => saveScores(scoringSubmission)}><Star className="mr-2 h-4 w-4" />Save Scores</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,5 @@
<?php
use Illuminate\Support\Facades\Route;
// API routes placeholder for future use

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\BiddingManagement\Http\Controllers\BidAwardController;
use Modules\BiddingManagement\Http\Controllers\BidInvitationController;
use Modules\BiddingManagement\Http\Controllers\BidPackageController;
use Modules\BiddingManagement\Http\Controllers\BidScoreController;
use Modules\BiddingManagement\Http\Controllers\BidSubmissionController;
Route::middleware(['web', 'auth', 'permission:bidding.access'])->group(function () {
// --- Bid Packages (PM) ---
Route::resource('bids', BidPackageController::class)->parameters(['bids' => 'bid']);
Route::post('bids/{bid}/publish', [BidPackageController::class, 'publish'])->name('bids.publish');
Route::post('bids/{bid}/evaluate', [BidPackageController::class, 'startEvaluation'])->name('bids.evaluate');
Route::post('bids/{bid}/cancel', [BidPackageController::class, 'cancel'])->name('bids.cancel');
// --- Invitations (PM manages) ---
Route::post('bids/{bid}/invitations', [BidInvitationController::class, 'store'])->name('bid-invitations.store');
Route::delete('bid-invitations/{invitation}', [BidInvitationController::class, 'destroy'])->name('bid-invitations.destroy');
// --- Invitation responses (Contractor) ---
Route::post('bid-invitations/{invitation}/accept', [BidInvitationController::class, 'accept'])->name('bid-invitations.accept');
Route::post('bid-invitations/{invitation}/decline', [BidInvitationController::class, 'decline'])->name('bid-invitations.decline');
// --- Submissions (Contractor submits, PM reviews) ---
Route::get('bid-invitations/{invitation}/submit', [BidSubmissionController::class, 'create'])->name('bid-submissions.create');
Route::post('bid-invitations/{invitation}/submit', [BidSubmissionController::class, 'store'])->name('bid-submissions.store');
Route::get('bid-submissions/{submission}', [BidSubmissionController::class, 'show'])->name('bid-submissions.show');
Route::post('bid-submissions/{submission}/shortlist', [BidSubmissionController::class, 'shortlist'])->name('bid-submissions.shortlist');
Route::post('bid-submissions/{submission}/reject', [BidSubmissionController::class, 'reject'])->name('bid-submissions.reject');
// --- Scoring (Evaluator) ---
Route::post('bid-submissions/{submission}/scores', [BidScoreController::class, 'store'])->name('bid-scores.store');
// --- Award (PM) ---
Route::post('bids/{bid}/award', [BidAwardController::class, 'store'])->name('bids.award');
// --- Contractor: My Bids dashboard ---
Route::get('my-bids', [BidSubmissionController::class, 'myBids'])->name('bids.my');
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export const paths = [
__dirname + '/resources/assets/sass/app.scss',
__dirname + '/resources/assets/js/app.js',
];
export default defineConfig({
build: {
outDir: '../../public/build-biddingmanagement',
emptyOutDir: true,
manifest: true,
},
plugins: [
laravel({
publicDirectory: '../../public',
buildDirectory: 'build-biddingmanagement',
input: paths,
refresh: true,
}),
],
resolve: {
alias: {
'@': __dirname + '/resources/js',
},
},
});

View File

@@ -0,0 +1,19 @@
<?php
namespace Modules\ContractorManagement\Enums;
enum EquipmentStatus: string
{
case Available = 'available';
case Deployed = 'deployed';
case Maintenance = 'maintenance';
public function label(): string
{
return match ($this) {
self::Available => 'Available',
self::Deployed => 'Deployed',
self::Maintenance => 'Under Maintenance',
};
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Modules\ContractorManagement\Enums;
enum InvoiceStatus: string
{
case Draft = 'draft';
case Submitted = 'submitted';
case Approved = 'approved';
case Paid = 'paid';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Submitted => 'Submitted',
self::Approved => 'Approved',
self::Paid => 'Paid',
self::Rejected => 'Rejected',
};
}
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::Submitted],
self::Submitted => [self::Approved, self::Rejected],
self::Approved => [self::Paid],
self::Paid => [],
self::Rejected => [self::Draft],
};
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\ContractorManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ContractorManagement\Models\Contractor;
class ContractorApproved
{
use Dispatchable, SerializesModels;
public function __construct(
public Contractor $contractor,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Modules\ContractorManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ContractorManagement\Models\Contractor;
use Modules\ProjectManagement\Models\Project;
class ContractorAssignedToProject
{
use Dispatchable, SerializesModels;
public function __construct(
public Contractor $contractor,
public Project $project,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\ContractorManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ContractorManagement\Models\ContractorInvoice;
class ContractorInvoicePaid
{
use Dispatchable, SerializesModels;
public function __construct(
public ContractorInvoice $invoice,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\ContractorManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ContractorManagement\Models\ContractorInvoice;
class ContractorInvoiceSubmitted
{
use Dispatchable, SerializesModels;
public function __construct(
public ContractorInvoice $invoice,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\ContractorManagement\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\ContractorManagement\Models\Contractor;
class ContractorOnboarded
{
use Dispatchable, SerializesModels;
public function __construct(
public Contractor $contractor,
) {}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace Modules\ContractorManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Modules\ContractorManagement\Enums\InvoiceStatus;
use Modules\ContractorManagement\Events\ContractorInvoicePaid;
use Modules\ContractorManagement\Events\ContractorInvoiceSubmitted;
use Modules\ContractorManagement\Models\Contractor;
use Modules\ContractorManagement\Models\ContractorInvoice;
use Modules\ProjectManagement\Models\Project;
use Spatie\Permission\Models\Role;
class ContractorController extends Controller
{
public function index(Request $request)
{
$query = Contractor::query();
if ($search = $request->search) {
$query->where(function ($q) use ($search) {
$q->where('company_name', 'like', "%{$search}%")
->orWhere('contact_person', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($spec = $request->specialization) {
$query->where('specialization', $spec);
}
if ($status = $request->status) {
$query->where('status', $status);
}
$contractors = $query->withCount(['equipment', 'certifications', 'invoices', 'projects'])
->latest()
->paginate(15)
->withQueryString();
return Inertia::render('ContractorManagement::Contractors/Index', [
'contractors' => $contractors,
'filters' => $request->only(['search', 'specialization', 'status']),
]);
}
public function create()
{
return Inertia::render('ContractorManagement::Contractors/Create');
}
public function store(Request $request)
{
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'contact_person' => 'required|string|max:255',
'email' => 'required|email|unique:contractors',
'phone' => 'nullable|string|max:50',
'specialization' => 'nullable|string|max:100',
'address' => 'nullable|string',
'tax_id' => 'nullable|string|max:50',
'payment_terms' => 'required|in:net_15,net_30,net_60',
'shares_materials_catalog' => 'nullable|boolean',
'create_admin' => 'nullable|boolean',
'admin_name' => 'required_if:create_admin,true|string|max:255',
'admin_email' => 'required_if:create_admin,true|email|unique:users,email',
'admin_password' => 'required_if:create_admin,true|string|min:8',
]);
DB::transaction(function () use ($validated) {
$contractorData = collect($validated)->except([
'create_admin',
'admin_name',
'admin_email',
'admin_password'
])->toArray();
$contractor = Contractor::create($contractorData);
if (!empty($validated['create_admin']) && $validated['create_admin']) {
$user = User::create([
'name' => $validated['admin_name'],
'email' => $validated['admin_email'],
'password' => Hash::make($validated['admin_password']),
'user_type' => 'contractor',
'contractor_id' => $contractor->id,
'status' => 'active',
'must_change_password' => true,
]);
$role = Role::firstOrCreate(['name' => 'Contractor']);
$user->assignRole($role);
}
});
return redirect()->route('contractors.index')->with('success', 'Contractor created successfully.');
}
public function show(Contractor $contractor)
{
$contractor->load([
'equipment.deployments.project:id,name',
'certifications',
'invoices.project:id,name',
'projects:id,name,code,status',
]);
$projects = Project::select('id', 'ulid', 'name', 'code')->get();
$employees = User::select('id', 'ulid', 'name')->get();
return Inertia::render('ContractorManagement::Contractors/Show', [
'contractor' => $contractor,
'projects' => $projects,
'employees' => $employees,
]);
}
public function edit(Contractor $contractor)
{
return Inertia::render('ContractorManagement::Contractors/Edit', [
'contractor' => $contractor,
]);
}
public function update(Request $request, Contractor $contractor)
{
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'contact_person' => 'required|string|max:255',
'email' => "required|email|unique:contractors,email,{$contractor->id}",
'phone' => 'nullable|string|max:50',
'specialization' => 'nullable|string|max:100',
'address' => 'nullable|string',
'tax_id' => 'nullable|string|max:50',
'payment_terms' => 'required|in:net_15,net_30,net_60',
'rating' => 'nullable|numeric|min:0|max:5',
'shares_materials_catalog' => 'nullable|boolean',
]);
$contractor->update($validated);
return redirect()->route('contractors.show', $contractor)->with('success', 'Contractor updated.');
}
public function destroy(Contractor $contractor)
{
$contractor->delete();
return redirect()->route('contractors.index')->with('success', 'Contractor deleted.');
}
// --- Equipment ---
public function storeEquipment(Request $request, Contractor $contractor)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'nullable|string|max:50',
'serial_number' => 'nullable|string|max:100',
'daily_rate' => 'nullable|numeric|min:0',
]);
$contractor->equipment()->create($validated);
return back()->with('success', 'Equipment added.');
}
// --- Certifications ---
public function storeCertification(Request $request, Contractor $contractor)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'certification_number' => 'nullable|string|max:100',
'issued_date' => 'nullable|date',
'expiry_date' => 'nullable|date|after:issued_date',
]);
$contractor->certifications()->create($validated);
return back()->with('success', 'Certification added.');
}
// --- Project Assignment ---
public function assignProject(Request $request, Contractor $contractor)
{
$validated = $request->validate([
'project_id' => 'required|string',
'role' => 'required|in:subcontractor,supplier,consultant',
'contract_amount' => 'nullable|numeric|min:0',
]);
$projectId = Project::resolveUlidToId($validated['project_id']);
$contractor->projects()->syncWithoutDetaching([
$projectId => [
'role' => $validated['role'],
'contract_amount' => $validated['contract_amount'] ?? 0,
],
]);
return back()->with('success', 'Contractor assigned to project.');
}
public function removeProject(Contractor $contractor, Project $project)
{
$contractor->projects()->detach($project->id);
return back()->with('success', 'Contractor removed from project.');
}
// --- Invoices ---
public function storeInvoice(Request $request, Contractor $contractor)
{
$validated = $request->validate([
'project_id' => 'nullable|string',
'invoice_number' => 'required|string|unique:contractor_invoices',
'amount' => 'required|numeric|min:0.01',
'description' => 'nullable|string',
'invoice_date' => 'required|date',
'due_date' => 'nullable|date',
]);
// Resolve ULID → ID for project
if (!empty($validated['project_id'])) {
$validated['project_id'] = Project::resolveUlidToId($validated['project_id']);
}
$contractor->invoices()->create($validated);
return back()->with('success', 'Invoice created.');
}
public function submitInvoice(ContractorInvoice $invoice)
{
try {
$invoice->transitionTo(InvoiceStatus::Submitted);
ContractorInvoiceSubmitted::dispatch($invoice);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Invoice submitted for approval.');
}
public function approveInvoice(ContractorInvoice $invoice)
{
try {
$invoice->transitionTo(InvoiceStatus::Approved);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Invoice approved.');
}
public function payInvoice(Request $request, ContractorInvoice $invoice)
{
$validated = $request->validate([
'amount' => 'required|numeric|min:0.01',
'payment_method' => 'required|in:bank_transfer,check,cash',
'reference_number' => 'nullable|string',
'payment_date' => 'required|date',
]);
$invoice->payments()->create($validated);
try {
$invoice->transitionTo(InvoiceStatus::Paid);
ContractorInvoicePaid::dispatch($invoice);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'Payment recorded and invoice marked as paid.');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Modules\ContractorManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ContractorManagementController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('contractormanagement::index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('contractormanagement::create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request) {}
/**
* Show the specified resource.
*/
public function show($id)
{
return view('contractormanagement::show');
}
/**
* Show the form for editing the specified resource.
*/
public function edit($id)
{
return view('contractormanagement::edit');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, $id) {}
/**
* Remove the specified resource from storage.
*/
public function destroy($id) {}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace Modules\ContractorManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Modules\ContractorManagement\Events\ContractorApproved;
use Modules\ContractorManagement\Events\ContractorOnboarded;
use Modules\ContractorManagement\Models\Contractor;
use Spatie\Permission\Models\Role;
class ContractorOnboardingController extends Controller
{
/**
* Show the contractor registration form.
*/
public function showRegistrationForm()
{
return Inertia::render('ContractorManagement::Contractors/Register');
}
/**
* Handle guest contractor onboarding registration.
*/
public function register(Request $request)
{
$validated = $request->validate([
// Contractor info
'company_name' => 'required|string|max:255',
'contact_person' => 'required|string|max:255',
'email' => 'required|email|unique:contractors,email',
'phone' => 'nullable|string|max:50',
'specialization' => 'nullable|string|max:100',
'address' => 'nullable|string',
'tax_id' => 'nullable|string|max:50',
'payment_terms' => 'required|in:net_15,net_30,net_60',
// Admin User info
'admin_name' => 'required|string|max:255',
'admin_email' => 'required|email|unique:users,email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
DB::transaction(function () use ($validated) {
// 1. Create the contractor in 'pending' status
$contractor = Contractor::create([
'company_name' => $validated['company_name'],
'contact_person' => $validated['contact_person'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'specialization' => $validated['specialization'] ?? null,
'address' => $validated['address'] ?? null,
'tax_id' => $validated['tax_id'] ?? null,
'payment_terms' => $validated['payment_terms'],
'status' => 'pending',
'type' => 'main', // Default to main if self-registered
]);
// Ensure the Contractor role exists
$role = Role::firstOrCreate(['name' => 'Contractor']);
// 2. Create the inactive admin user for this contractor
$user = User::create([
'name' => $validated['admin_name'],
'email' => $validated['admin_email'],
'password' => Hash::make($validated['password']),
'user_type' => 'admin',
'status' => 'inactive', // Inactive until approved
'contractor_id' => $contractor->id,
]);
$user->assignRole($role);
event(new ContractorOnboarded($contractor));
});
return redirect()->route('login')->with('success', 'Your contractor registration has been submitted and is pending system approval.');
}
/**
* List all pending contractor onboarding requests for Platform Owner review.
*/
public function pending()
{
// Only Platform Owners (contractor_id === null) should access this
if (auth()->user()->contractor_id !== null) {
abort(403, 'Unauthorized access.');
}
// Fetch contractors in pending status
$pendingContractors = Contractor::where('status', 'pending')
->withCount('users')
->latest()
->get();
return Inertia::render('ContractorManagement::Contractors/Pending', [
'contractors' => $pendingContractors,
]);
}
/**
* Approve a pending contractor and activate their primary admin user.
*/
public function approve(Contractor $contractor)
{
if (auth()->user()->contractor_id !== null) {
abort(403, 'Unauthorized access.');
}
if ($contractor->status !== 'pending') {
return back()->with('error', 'Only pending contractors can be approved.');
}
DB::transaction(function () use ($contractor) {
// Update contractor status to active
$contractor->update(['status' => 'active']);
// Activate associated users
User::where('contractor_id', $contractor->id)
->where('status', 'inactive')
->update(['status' => 'active']);
event(new ContractorApproved($contractor));
});
return back()->with('success', "Contractor '{$contractor->company_name}' has been successfully approved.");
}
/**
* Reject a pending contractor registration and delete the submission.
*/
public function reject(Contractor $contractor)
{
if (auth()->user()->contractor_id !== null) {
abort(403, 'Unauthorized access.');
}
if ($contractor->status !== 'pending') {
return back()->with('error', 'Only pending contractors can be rejected.');
}
DB::transaction(function () use ($contractor) {
// Delete all users belonging to this contractor
User::where('contractor_id', $contractor->id)->delete();
// Delete the contractor record
$contractor->delete();
});
return back()->with('success', 'Contractor registration request has been rejected and deleted.');
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Modules\ContractorManagement\Livewire;
use Livewire\Component;
use Modules\ContractorManagement\Models\Contractor;
use Illuminate\Support\Facades\Auth;
class ContractorManager extends Component
{
public $contractors;
public $company_name;
public $type = 'main';
public $user_limit = 10;
public $parent_id = null;
protected $rules = [
'company_name' => 'required|string|max:255',
'type' => 'required|in:main,sub',
'user_limit' => 'required|integer|min:0',
'parent_id' => 'nullable|exists:contractors,id'
];
public function mount()
{
$this->loadContractors();
}
public function loadContractors()
{
$user = Auth::user();
if ($user->hasRole('admin')) {
$this->contractors = Contractor::all();
} elseif ($user->hasRole('Main Contractor Admin') && $user->contractor) {
$this->contractors = Contractor::where('id', $user->contractor_id)
->orWhere('parent_id', $user->contractor_id)
->get();
} else {
$this->contractors = collect();
}
}
public function createContractor()
{
$this->validate();
$user = Auth::user();
// Enforce hierarchy rules
if (!$user->hasRole('admin')) {
if ($this->type === 'main') {
session()->flash('error', 'Only admins can create Main Contractors.');
return;
}
if ($user->hasRole('Main Contractor Admin')) {
$this->parent_id = $user->contractor_id; // force parent to be themselves
}
}
Contractor::create([
'company_name' => $this->company_name,
'type' => $this->type,
'user_limit' => $this->user_limit,
'parent_id' => $this->parent_id,
]);
$this->reset(['company_name', 'type', 'user_limit', 'parent_id']);
$this->loadContractors();
session()->flash('message', 'Contractor created successfully.');
}
public function render()
{
return view('contractormanagement::livewire.contractor-manager');
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Modules\ContractorManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Modules\BiddingManagement\Models\BidInvitation;
use Modules\ProjectManagement\Models\Project;
class Contractor extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'company_name', 'contact_person', 'email', 'phone',
'specialization', 'address', 'tax_id',
'payment_terms', 'rating', 'status',
'type', 'parent_id', 'user_limit',
'shares_materials_catalog',
];
protected function casts(): array
{
return [
'rating' => 'decimal:1',
'user_limit' => 'integer',
'shares_materials_catalog' => 'boolean',
];
}
public function equipment(): HasMany
{
return $this->hasMany(ContractorEquipment::class);
}
public function users(): HasMany
{
return $this->hasMany(\App\Models\User::class);
}
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function certifications(): HasMany
{
return $this->hasMany(ContractorCertification::class);
}
public function bidInvitations(): HasMany
{
return $this->hasMany(BidInvitation::class);
}
public function invoices(): HasMany
{
return $this->hasMany(ContractorInvoice::class);
}
public function projects(): BelongsToMany
{
return $this->belongsToMany(Project::class, 'project_contractor')
->withPivot('role', 'contract_amount')
->withTimestamps();
}
public function getActiveCertificationsCountAttribute(): int
{
return $this->certifications()->where('status', 'active')->count();
}
public function getExpiringCertificationsCountAttribute(): int
{
return $this->certifications()
->where('status', 'active')
->where('expiry_date', '<=', now()->addDays(30))
->where('expiry_date', '>', now())
->count();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Modules\ContractorManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContractorCertification extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'contractor_id', 'name', 'certification_number',
'issued_date', 'expiry_date', 'status',
];
protected function casts(): array
{
return [
'issued_date' => 'date',
'expiry_date' => 'date',
];
}
public function contractor(): BelongsTo
{
return $this->belongsTo(Contractor::class);
}
public function getIsExpiringSoonAttribute(): bool
{
if (!$this->expiry_date) return false;
return $this->expiry_date->isBetween(now(), now()->addDays(30));
}
public function getIsExpiredAttribute(): bool
{
if (!$this->expiry_date) return false;
return $this->expiry_date->isPast();
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Modules\ContractorManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\ContractorManagement\Enums\EquipmentStatus;
class ContractorEquipment extends Model
{
use HasPublicIdentifier;
protected $table = 'contractor_equipment';
protected $fillable = [
'contractor_id', 'name', 'type', 'serial_number',
'status', 'daily_rate', 'last_maintenance_date',
];
protected function casts(): array
{
return [
'status' => EquipmentStatus::class,
'daily_rate' => 'decimal:2',
'last_maintenance_date' => 'date',
];
}
public function contractor(): BelongsTo
{
return $this->belongsTo(Contractor::class);
}
public function deployments(): HasMany
{
return $this->hasMany(EquipmentDeployment::class, 'contractor_equipment_id');
}
public function activeDeployment(): ?EquipmentDeployment
{
return $this->deployments()->whereNull('returned_date')->first();
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Modules\ContractorManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\ContractorManagement\Enums\InvoiceStatus;
use Modules\ApprovalWorkflow\Traits\HasApprovable;
use Modules\ProjectManagement\Models\Project;
class ContractorInvoice extends Model
{
use HasApprovable, HasPublicIdentifier;
protected $fillable = [
'contractor_id', 'project_id', 'invoice_number',
'status', 'amount', 'description',
'invoice_date', 'due_date', 'paid_at',
];
protected function casts(): array
{
return [
'status' => InvoiceStatus::class,
'amount' => 'decimal:2',
'invoice_date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
];
}
public function contractor(): BelongsTo
{
return $this->belongsTo(Contractor::class);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function payments(): HasMany
{
return $this->hasMany(ContractorPayment::class);
}
public function transitionTo(InvoiceStatus $newStatus): void
{
$allowed = $this->status->allowedTransitions();
if (!in_array($newStatus, $allowed)) {
throw new \InvalidArgumentException(
"Cannot transition invoice from {$this->status->label()} to {$newStatus->label()}"
);
}
$updates = ['status' => $newStatus];
if ($newStatus === InvoiceStatus::Paid) {
$updates['paid_at'] = now();
}
$this->update($updates);
}
public function getTotalPaidAttribute(): float
{
return (float) $this->payments()->sum('amount');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Modules\ContractorManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContractorPayment extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'contractor_invoice_id', 'amount', 'payment_method',
'reference_number', 'notes', 'payment_date',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'payment_date' => 'date',
];
}
public function invoice(): BelongsTo
{
return $this->belongsTo(ContractorInvoice::class, 'contractor_invoice_id');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Modules\ContractorManagement\Models;
use App\Traits\HasPublicIdentifier;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\ProjectManagement\Models\Project;
class EquipmentDeployment extends Model
{
use HasPublicIdentifier;
protected $fillable = [
'contractor_equipment_id', 'project_id',
'deployed_date', 'returned_date', 'notes',
];
protected function casts(): array
{
return [
'deployed_date' => 'date',
'returned_date' => 'date',
];
}
public function equipment(): BelongsTo
{
return $this->belongsTo(ContractorEquipment::class, 'contractor_equipment_id');
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function getIsActiveAttribute(): bool
{
return is_null($this->returned_date);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Modules\ContractorManagement\Providers;
use Nwidart\Modules\Support\ModuleServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
class ContractorManagementServiceProvider extends ModuleServiceProvider
{
/**
* The name of the module.
*/
protected string $name = 'ContractorManagement';
/**
* The lowercase version of the module name.
*/
protected string $nameLower = 'contractormanagement';
/**
* Command classes to register.
*
* @var string[]
*/
// protected array $commands = [];
/**
* Provider classes to register.
*
* @var string[]
*/
protected array $providers = [
EventServiceProvider::class,
RouteServiceProvider::class,
];
/**
* Define module schedules.
*
* @param $schedule
*/
// protected function configureSchedules(Schedule $schedule): void
// {
// $schedule->command('inspire')->hourly();
// }
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Modules\ContractorManagement\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*/
protected function configureEmailVerification(): void {}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Modules\ContractorManagement\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected string $name = 'ContractorManagement';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*/
public function boot(): void
{
parent::boot();
}
/**
* Define the routes for the application.
*/
public function map(): void
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*/
protected function mapWebRoutes(): void
{
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapApiRoutes(): void
{
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
}
}

Some files were not shown because too many files have changed in this diff Show More