chore: update document approval workflow and bug fixes
This commit is contained in:
31
Modules/ApprovalWorkflow/app/Enums/ApprovalChainStatus.php
Normal file
31
Modules/ApprovalWorkflow/app/Enums/ApprovalChainStatus.php
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
21
Modules/ApprovalWorkflow/app/Enums/ApprovalStepStatus.php
Normal file
21
Modules/ApprovalWorkflow/app/Enums/ApprovalStepStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
16
Modules/ApprovalWorkflow/app/Events/ApprovalCompleted.php
Normal file
16
Modules/ApprovalWorkflow/app/Events/ApprovalCompleted.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
18
Modules/ApprovalWorkflow/app/Events/ApprovalRequired.php
Normal file
18
Modules/ApprovalWorkflow/app/Events/ApprovalRequired.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
18
Modules/ApprovalWorkflow/app/Events/StepApproved.php
Normal file
18
Modules/ApprovalWorkflow/app/Events/StepApproved.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
18
Modules/ApprovalWorkflow/app/Events/StepRejected.php
Normal file
18
Modules/ApprovalWorkflow/app/Events/StepRejected.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Modules/ApprovalWorkflow/app/Models/ApprovalChain.php
Normal file
76
Modules/ApprovalWorkflow/app/Models/ApprovalChain.php
Normal 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);
|
||||
}
|
||||
}
|
||||
46
Modules/ApprovalWorkflow/app/Models/ApprovalStep.php
Normal file
46
Modules/ApprovalWorkflow/app/Models/ApprovalStep.php
Normal 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;
|
||||
}
|
||||
}
|
||||
0
Modules/ApprovalWorkflow/app/Providers/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/app/Providers/.gitkeep
Normal 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();
|
||||
// }
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
135
Modules/ApprovalWorkflow/app/Services/ApprovalService.php
Normal file
135
Modules/ApprovalWorkflow/app/Services/ApprovalService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
30
Modules/ApprovalWorkflow/app/Traits/HasApprovable.php
Normal file
30
Modules/ApprovalWorkflow/app/Traits/HasApprovable.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
30
Modules/ApprovalWorkflow/composer.json
Normal file
30
Modules/ApprovalWorkflow/composer.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
Modules/ApprovalWorkflow/config/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/config/.gitkeep
Normal file
5
Modules/ApprovalWorkflow/config/config.php
Normal file
5
Modules/ApprovalWorkflow/config/config.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'ApprovalWorkflow',
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
0
Modules/ApprovalWorkflow/database/seeders/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/database/seeders/.gitkeep
Normal 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([]);
|
||||
}
|
||||
}
|
||||
11
Modules/ApprovalWorkflow/module.json
Normal file
11
Modules/ApprovalWorkflow/module.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "ApprovalWorkflow",
|
||||
"alias": "approvalworkflow",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\ApprovalWorkflow\\Providers\\ApprovalWorkflowServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
15
Modules/ApprovalWorkflow/package.json
Normal file
15
Modules/ApprovalWorkflow/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
Modules/ApprovalWorkflow/resources/assets/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/resources/assets/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/resources/assets/js/app.js
Normal file
0
Modules/ApprovalWorkflow/resources/assets/js/app.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
147
Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx
Normal file
147
Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx
Normal file
157
Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
Modules/ApprovalWorkflow/resources/views/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/resources/views/.gitkeep
Normal 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>
|
||||
5
Modules/ApprovalWorkflow/resources/views/index.blade.php
Normal file
5
Modules/ApprovalWorkflow/resources/views/index.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<x-approvalworkflow::layouts.master>
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>Module: {!! config('approvalworkflow.name') !!}</p>
|
||||
</x-approvalworkflow::layouts.master>
|
||||
0
Modules/ApprovalWorkflow/routes/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/routes/.gitkeep
Normal file
8
Modules/ApprovalWorkflow/routes/api.php
Normal file
8
Modules/ApprovalWorkflow/routes/api.php
Normal 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');
|
||||
});
|
||||
11
Modules/ApprovalWorkflow/routes/web.php
Normal file
11
Modules/ApprovalWorkflow/routes/web.php
Normal 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');
|
||||
});
|
||||
0
Modules/ApprovalWorkflow/tests/Feature/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/tests/Feature/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/tests/Unit/.gitkeep
Normal file
0
Modules/ApprovalWorkflow/tests/Unit/.gitkeep
Normal file
41
Modules/ApprovalWorkflow/vite.config.js
Normal file
41
Modules/ApprovalWorkflow/vite.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
45
Modules/BiddingManagement/app/Enums/BidPackageStatus.php
Normal file
45
Modules/BiddingManagement/app/Enums/BidPackageStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
Modules/BiddingManagement/app/Enums/BidSubmissionStatus.php
Normal file
31
Modules/BiddingManagement/app/Enums/BidSubmissionStatus.php
Normal 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 => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
17
Modules/BiddingManagement/app/Enums/EvaluationMode.php
Normal file
17
Modules/BiddingManagement/app/Enums/EvaluationMode.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
14
Modules/BiddingManagement/app/Events/BidAwarded.php
Normal file
14
Modules/BiddingManagement/app/Events/BidAwarded.php
Normal 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) {}
|
||||
}
|
||||
14
Modules/BiddingManagement/app/Events/BidPackageOpened.php
Normal file
14
Modules/BiddingManagement/app/Events/BidPackageOpened.php
Normal 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) {}
|
||||
}
|
||||
14
Modules/BiddingManagement/app/Events/BidSubmitted.php
Normal file
14
Modules/BiddingManagement/app/Events/BidSubmitted.php
Normal 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) {}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
Modules/BiddingManagement/app/Models/BidAward.php
Normal file
73
Modules/BiddingManagement/app/Models/BidAward.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
Modules/BiddingManagement/app/Models/BidCriteria.php
Normal file
34
Modules/BiddingManagement/app/Models/BidCriteria.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
Modules/BiddingManagement/app/Models/BidInvitation.php
Normal file
64
Modules/BiddingManagement/app/Models/BidInvitation.php
Normal 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();
|
||||
}
|
||||
}
|
||||
126
Modules/BiddingManagement/app/Models/BidPackage.php
Normal file
126
Modules/BiddingManagement/app/Models/BidPackage.php
Normal 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();
|
||||
}
|
||||
}
|
||||
36
Modules/BiddingManagement/app/Models/BidScore.php
Normal file
36
Modules/BiddingManagement/app/Models/BidScore.php
Normal 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');
|
||||
}
|
||||
}
|
||||
89
Modules/BiddingManagement/app/Models/BidSubmission.php
Normal file
89
Modules/BiddingManagement/app/Models/BidSubmission.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
28
Modules/BiddingManagement/composer.json
Normal file
28
Modules/BiddingManagement/composer.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (0–100)
|
||||
$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); // 0–100
|
||||
$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');
|
||||
}
|
||||
};
|
||||
11
Modules/BiddingManagement/module.json
Normal file
11
Modules/BiddingManagement/module.json
Normal 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": []
|
||||
}
|
||||
15
Modules/BiddingManagement/package.json
Normal file
15
Modules/BiddingManagement/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
10
Modules/BiddingManagement/resources/js/Pages/Bids/Create.tsx
Normal file
10
Modules/BiddingManagement/resources/js/Pages/Bids/Create.tsx
Normal 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} />;
|
||||
}
|
||||
11
Modules/BiddingManagement/resources/js/Pages/Bids/Edit.tsx
Normal file
11
Modules/BiddingManagement/resources/js/Pages/Bids/Edit.tsx
Normal 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} />;
|
||||
}
|
||||
251
Modules/BiddingManagement/resources/js/Pages/Bids/Form.tsx
Normal file
251
Modules/BiddingManagement/resources/js/Pages/Bids/Form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
Modules/BiddingManagement/resources/js/Pages/Bids/Index.tsx
Normal file
182
Modules/BiddingManagement/resources/js/Pages/Bids/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
599
Modules/BiddingManagement/resources/js/Pages/Bids/Show.tsx
Normal file
599
Modules/BiddingManagement/resources/js/Pages/Bids/Show.tsx
Normal 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 0–100"
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
Modules/BiddingManagement/routes/api.php
Normal file
5
Modules/BiddingManagement/routes/api.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// API routes placeholder for future use
|
||||
42
Modules/BiddingManagement/routes/web.php
Normal file
42
Modules/BiddingManagement/routes/web.php
Normal 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');
|
||||
});
|
||||
28
Modules/BiddingManagement/vite.config.js
Normal file
28
Modules/BiddingManagement/vite.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
19
Modules/ContractorManagement/app/Enums/EquipmentStatus.php
Normal file
19
Modules/ContractorManagement/app/Enums/EquipmentStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
34
Modules/ContractorManagement/app/Enums/InvoiceStatus.php
Normal file
34
Modules/ContractorManagement/app/Enums/InvoiceStatus.php
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
88
Modules/ContractorManagement/app/Models/Contractor.php
Normal file
88
Modules/ContractorManagement/app/Models/Contractor.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
0
Modules/ContractorManagement/app/Providers/.gitkeep
Normal file
0
Modules/ContractorManagement/app/Providers/.gitkeep
Normal 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();
|
||||
// }
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user