chore: update document approval workflow and bug fixes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
<?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('contractors', function (Blueprint $table) {
$table->id();
$table->string('company_name');
$table->string('contact_person');
$table->string('email')->unique();
$table->string('phone')->nullable();
$table->string('specialization')->nullable(); // e.g., electrical, plumbing, structural
$table->text('address')->nullable();
$table->string('tax_id')->nullable();
$table->string('payment_terms')->default('net_30'); // net_15, net_30, net_60
$table->decimal('rating', 3, 1)->default(0); // 0-5 rating
$table->string('status')->default('active'); // active, inactive, blacklisted
$table->timestamps();
});
Schema::create('contractor_equipment', function (Blueprint $table) {
$table->id();
$table->foreignId('contractor_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('type')->nullable(); // heavy, light, vehicle
$table->string('serial_number')->nullable();
$table->string('status')->default('available'); // available, deployed, maintenance
$table->decimal('daily_rate', 12, 2)->default(0);
$table->date('last_maintenance_date')->nullable();
$table->timestamps();
});
Schema::create('contractor_certifications', function (Blueprint $table) {
$table->id();
$table->foreignId('contractor_id')->constrained()->cascadeOnDelete();
$table->string('name'); // e.g., ISO, PCAB License
$table->string('certification_number')->nullable();
$table->date('issued_date')->nullable();
$table->date('expiry_date')->nullable();
$table->string('status')->default('active'); // active, expired, revoked
$table->timestamps();
});
Schema::create('contractor_invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('contractor_id')->constrained()->cascadeOnDelete();
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->string('invoice_number')->unique();
$table->string('status')->default('draft'); // draft, submitted, approved, paid, rejected
$table->decimal('amount', 15, 2);
$table->text('description')->nullable();
$table->date('invoice_date');
$table->date('due_date')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
Schema::create('contractor_payments', function (Blueprint $table) {
$table->id();
$table->foreignId('contractor_invoice_id')->constrained()->cascadeOnDelete();
$table->decimal('amount', 15, 2);
$table->string('payment_method')->default('bank_transfer');
$table->string('reference_number')->nullable();
$table->text('notes')->nullable();
$table->date('payment_date');
$table->timestamps();
});
Schema::create('equipment_deployments', function (Blueprint $table) {
$table->id();
$table->foreignId('contractor_equipment_id')->constrained('contractor_equipment')->cascadeOnDelete();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->date('deployed_date');
$table->date('returned_date')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
Schema::create('project_contractor', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('contractor_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('subcontractor'); // subcontractor, supplier, consultant
$table->decimal('contract_amount', 15, 2)->default(0);
$table->timestamps();
$table->unique(['project_id', 'contractor_id']);
});
}
public function down(): void
{
Schema::dropIfExists('project_contractor');
Schema::dropIfExists('equipment_deployments');
Schema::dropIfExists('contractor_payments');
Schema::dropIfExists('contractor_invoices');
Schema::dropIfExists('contractor_certifications');
Schema::dropIfExists('contractor_equipment');
Schema::dropIfExists('contractors');
}
};

View File

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

View File

@@ -0,0 +1,11 @@
{
"name": "ContractorManagement",
"alias": "contractormanagement",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\ContractorManagement\\Providers\\ContractorManagementServiceProvider"
],
"files": []
}

View File

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

View File

@@ -0,0 +1,2 @@
import Form from './Form';
export default Form;

View File

@@ -0,0 +1,2 @@
import Form from './Form';
export default Form;

View File

@@ -0,0 +1,198 @@
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 { Checkbox } from '@/Components/ui/checkbox';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import { ArrowLeft, Save } from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent } from 'react';
interface Props extends PageProps {
contractor?: {
id: number; ulid: string; company_name: string; contact_person: string; email: string;
phone?: string; specialization?: string; address?: string; tax_id?: string;
payment_terms: string; rating?: string; shares_materials_catalog?: boolean;
};
}
export default function Form({ contractor }: Props) {
const isEdit = !!contractor;
const form = useForm({
company_name: contractor?.company_name || '',
contact_person: contractor?.contact_person || '',
email: contractor?.email || '',
phone: contractor?.phone || '',
specialization: contractor?.specialization || '',
address: contractor?.address || '',
tax_id: contractor?.tax_id || '',
payment_terms: contractor?.payment_terms || 'net_30',
rating: contractor?.rating || '',
shares_materials_catalog: contractor?.shares_materials_catalog !== false,
create_admin: false,
admin_name: '',
admin_email: '',
admin_password: '',
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (isEdit) {
form.put(route('contractors.update', contractor!.ulid));
} else {
form.post(route('contractors.store'));
}
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-4">
<Link href={route('contractors.index')}>
<Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<h2 className="text-xl font-semibold leading-tight text-gray-800">
{isEdit ? 'Edit Contractor' : 'New Contractor'}
</h2>
</div>
}
>
<Head title={isEdit ? 'Edit Contractor' : 'New Contractor'} />
<div className="py-6">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<Card>
<CardHeader><CardTitle>Contractor Details</CardTitle></CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="company_name">Company Name *</Label>
<Input id="company_name" value={form.data.company_name} onChange={(e) => form.setData('company_name', e.target.value)} />
{form.errors.company_name && <p className="text-sm text-red-500">{form.errors.company_name}</p>}
</div>
<div>
<Label htmlFor="contact_person">Contact Person *</Label>
<Input id="contact_person" value={form.data.contact_person} onChange={(e) => form.setData('contact_person', e.target.value)} />
{form.errors.contact_person && <p className="text-sm text-red-500">{form.errors.contact_person}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="email">Email *</Label>
<Input id="email" type="email" value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} />
{form.errors.email && <p className="text-sm text-red-500">{form.errors.email}</p>}
</div>
<div>
<Label htmlFor="phone">Phone</Label>
<Input id="phone" value={form.data.phone} onChange={(e) => form.setData('phone', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="specialization">Specialization</Label>
<Select value={form.data.specialization} onValueChange={(v) => { if (v) form.setData('specialization', v); }} items={[{ value: 'general', label: 'General' }, { value: 'electrical', label: 'Electrical' }, { value: 'plumbing', label: 'Plumbing' }, { value: 'structural', label: 'Structural' }, { value: 'hvac', label: 'HVAC' }, { value: 'painting', label: 'Painting' }, { value: 'roofing', label: 'Roofing' }, { value: 'landscaping', label: 'Landscaping' }]}>
<SelectTrigger><SelectValue placeholder="Select specialization" /></SelectTrigger>
<SelectContent>
<SelectItem value="general">General</SelectItem>
<SelectItem value="electrical">Electrical</SelectItem>
<SelectItem value="plumbing">Plumbing</SelectItem>
<SelectItem value="structural">Structural</SelectItem>
<SelectItem value="hvac">HVAC</SelectItem>
<SelectItem value="painting">Painting</SelectItem>
<SelectItem value="roofing">Roofing</SelectItem>
<SelectItem value="landscaping">Landscaping</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="payment_terms">Payment Terms *</Label>
<Select value={form.data.payment_terms} onValueChange={(v) => { if (v) form.setData('payment_terms', v); }} items={[{ value: 'net_15', label: 'Net 15' }, { value: 'net_30', label: 'Net 30' }, { value: 'net_60', label: 'Net 60' }]}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="net_15">Net 15</SelectItem>
<SelectItem value="net_30">Net 30</SelectItem>
<SelectItem value="net_60">Net 60</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="tax_id">Tax ID</Label>
<Input id="tax_id" value={form.data.tax_id} onChange={(e) => form.setData('tax_id', e.target.value)} />
</div>
{isEdit && (
<div>
<Label htmlFor="rating">Rating (0-5)</Label>
<Input id="rating" type="number" step="0.5" min="0" max="5" value={form.data.rating} onChange={(e) => form.setData('rating', e.target.value)} />
</div>
)}
</div>
<div>
<Label htmlFor="address">Address</Label>
<Input id="address" value={form.data.address} onChange={(e) => form.setData('address', e.target.value)} />
</div>
<div className="flex items-center gap-2 py-2">
<Checkbox
id="shares_materials_catalog"
checked={form.data.shares_materials_catalog}
onChange={(e) => form.setData('shares_materials_catalog', e.target.checked)}
/>
<Label htmlFor="shares_materials_catalog" className="text-sm font-medium text-gray-700 cursor-pointer">
Share Global Materials Catalog with this Contractor
</Label>
</div>
{!isEdit && (
<div className="border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-4">
<Checkbox
id="create_admin"
checked={form.data.create_admin}
onChange={(e) => form.setData('create_admin', e.target.checked)}
/>
<Label htmlFor="create_admin" className="text-sm font-medium text-gray-700 cursor-pointer">
Provision Contractor Admin Account
</Label>
</div>
{form.data.create_admin && (
<div className="space-y-4 pl-6 border-l-2 border-gray-100">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="admin_name">Admin Name *</Label>
<Input id="admin_name" value={form.data.admin_name} onChange={(e) => form.setData('admin_name', e.target.value)} />
{form.errors.admin_name && <p className="text-sm text-red-500">{form.errors.admin_name}</p>}
</div>
<div>
<Label htmlFor="admin_email">Admin Email *</Label>
<Input id="admin_email" type="email" value={form.data.admin_email} onChange={(e) => form.setData('admin_email', e.target.value)} />
{form.errors.admin_email && <p className="text-sm text-red-500">{form.errors.admin_email}</p>}
</div>
</div>
<div>
<Label htmlFor="admin_password">Admin Password *</Label>
<Input id="admin_password" type="password" value={form.data.admin_password} onChange={(e) => form.setData('admin_password', e.target.value)} />
{form.errors.admin_password && <p className="text-sm text-red-500">{form.errors.admin_password}</p>}
</div>
</div>
)}
</div>
)}
<div className="flex justify-end pt-4">
<Button type="submit" disabled={form.processing}>
<Save className="mr-2 h-4 w-4" /> {isEdit ? 'Update' : 'Create'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,149 @@
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, Pencil, Trash2, HardHat, Star } from 'lucide-react';
import { FormEvent, useState } from 'react';
interface ContractorItem {
id: number; ulid: string; company_name: string; contact_person: string; email: string;
specialization?: string; payment_terms: string; rating: string; status: string;
equipment_count: number; certifications_count: number; invoices_count: number; projects_count: number;
}
interface Props extends PageProps {
contractors: PaginatedData<ContractorItem>;
filters: { search?: string; specialization?: string; status?: string };
}
const statusVariant = (s: string) => {
switch (s) { case 'active': return 'default'; case 'inactive': return 'secondary'; case 'blacklisted': return 'destructive'; default: return 'outline'; }
};
export default function Index({ contractors, filters }: Props) {
const { flash } = usePage<PageProps>().props;
const [search, setSearch] = useState(filters.search || '');
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
const applyFilters = (e?: FormEvent) => {
e?.preventDefault();
router.get(route('contractors.index'), {
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}, { preserveState: true, replace: true });
};
const handleDelete = (c: ContractorItem) => {
if (confirm(`Delete "${c.company_name}"?`)) {
router.delete(route('contractors.destroy', c.ulid));
}
};
const renderStars = (rating: string) => {
const r = Number(rating);
return Array.from({ length: 5 }, (_, i) => (
<Star key={i} className={`h-3 w-3 ${i < r ? 'fill-amber-400 text-amber-400' : 'text-gray-300'}`} />
));
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-2">
<HardHat className="h-5 w-5" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">Contractors</h2>
</div>
}
>
<Head title="Contractors" />
<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>}
<Card>
<DataTableToolbar
searchValue={search}
searchPlaceholder="Search company, contact, email..."
onSearchChange={setSearch}
onSearchSubmit={applyFilters}
filters={
<Select value={statusFilter} onValueChange={(v) => { if (v) setStatusFilter(v); }} items={[{ value: 'all', label: 'All Statuses' }, { value: 'active', label: 'Active' }, { value: 'inactive', label: 'Inactive' }, { value: 'blacklisted', label: 'Blacklisted' }]}>
<SelectTrigger className="w-[150px]"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="blacklisted">Blacklisted</SelectItem>
</SelectContent>
</Select>
}
actions={
<Link href={route('contractors.create')}>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Contractor</Button>
</Link>
}
/>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Specialization</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contractors.data.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center text-gray-500 py-8">No contractors found.</TableCell></TableRow>
) : (
contractors.data.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.company_name}</TableCell>
<TableCell className="text-gray-500">{c.contact_person}</TableCell>
<TableCell><Badge variant="outline">{c.specialization || '-'}</Badge></TableCell>
<TableCell><div className="flex gap-0.5">{renderStars(c.rating)}</div></TableCell>
<TableCell><Badge variant={statusVariant(c.status)}>{c.status}</Badge></TableCell>
<TableCell className="text-center">{c.projects_count}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Link href={route('contractors.show', c.ulid)}><Button variant="ghost" size="icon-sm" title="View"><Eye className="h-4 w-4" /></Button></Link>
<Link href={route('contractors.edit', c.ulid)}><Button variant="ghost" size="icon-sm" title="Edit"><Pencil className="h-4 w-4" /></Button></Link>
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => handleDelete(c)}><Trash2 className="h-4 w-4 text-red-500" /></Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{contractors.last_page > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600">Showing {contractors.from} to {contractors.to} of {contractors.total}</p>
<div className="flex gap-1">
{contractors.prev_page_url && <Link href={contractors.prev_page_url}><Button variant="outline" size="sm">Previous</Button></Link>}
{contractors.next_page_url && <Link href={contractors.next_page_url}><Button variant="outline" size="sm">Next</Button></Link>}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,356 @@
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 { 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, Star, AlertTriangle,
Send, CheckCircle2, DollarSign, HardHat,
} from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent, useMemo, useState } from 'react';
interface Equipment {
id: number; ulid: string; name: string; type?: string; serial_number?: string;
status: string; daily_rate: string;
deployments: { id: number; ulid: string; deployed_date: string; returned_date?: string; project: { id: number; ulid: string; name: string } }[];
}
interface Certification {
id: number; ulid: string; name: string; certification_number?: string;
issued_date?: string; expiry_date?: string; status: string;
}
interface Invoice {
id: number; ulid: string; invoice_number: string; status: string; amount: string;
invoice_date: string; due_date?: string; paid_at?: string;
project?: { id: number; ulid: string; name: string };
}
interface ProjectItem { id: number; ulid: string; name: string; code: string; status: string; pivot?: { role: string; contract_amount: string } }
interface ContractorData {
id: number; ulid: string; company_name: string; contact_person: string; email: string;
phone?: string; specialization?: string; address?: string; tax_id?: string;
payment_terms: string; rating: string; status: string;
equipment: Equipment[]; certifications: Certification[];
invoices: Invoice[]; projects: ProjectItem[];
}
interface Props extends PageProps {
contractor: ContractorData;
projects: { id: number; ulid: string; name: string; code: string }[];
employees: { id: number; ulid: string; name: string }[];
}
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatCurrency = (v: string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
const isExpiringSoon = (d?: string) => d && new Date(d) <= new Date(Date.now() + 30 * 86400000) && new Date(d) > new Date();
const isExpired = (d?: string) => d && new Date(d) < new Date();
const invoiceStatusVariant = (s: string) => {
switch (s) { case 'paid': return 'default'; case 'approved': return 'secondary'; case 'rejected': return 'destructive'; default: return 'outline'; }
};
export default function Show({ contractor, projects, employees }: Props) {
const { flash } = usePage<PageProps>().props;
const [equipDialog, setEquipDialog] = useState(false);
const [certDialog, setCertDialog] = useState(false);
const [projDialog, setProjDialog] = useState(false);
const [invDialog, setInvDialog] = useState(false);
const equipForm = useForm({ name: '', type: '', serial_number: '', daily_rate: '' });
const certForm = useForm({ name: '', certification_number: '', issued_date: '', expiry_date: '' });
const projForm = useForm({ project_id: '', role: 'subcontractor', contract_amount: '' });
const invForm = useForm({ project_id: '', invoice_number: '', amount: '', description: '', invoice_date: '', due_date: '' });
// Items arrays for Select label lookup
const equipTypeItems = useMemo(() => [{ value: 'heavy', label: 'Heavy' }, { value: 'light', label: 'Light' }, { value: 'vehicle', label: 'Vehicle' }], []);
const projectSelectItems = useMemo(() => projects.map(p => ({ value: p.ulid, label: `${p.name} (${p.code})` })), [projects]);
const roleItems = useMemo(() => [{ value: 'subcontractor', label: 'Subcontractor' }, { value: 'supplier', label: 'Supplier' }, { value: 'consultant', label: 'Consultant' }], []);
const submitEquip = (e: FormEvent) => { e.preventDefault(); equipForm.post(route('contractors.equipment.store', contractor.ulid), { onSuccess: () => { equipForm.reset(); setEquipDialog(false); } }); };
const submitCert = (e: FormEvent) => { e.preventDefault(); certForm.post(route('contractors.certifications.store', contractor.ulid), { onSuccess: () => { certForm.reset(); setCertDialog(false); } }); };
const submitProj = (e: FormEvent) => { e.preventDefault(); projForm.post(route('contractors.projects.assign', contractor.ulid), { onSuccess: () => { projForm.reset(); setProjDialog(false); } }); };
const submitInv = (e: FormEvent) => { e.preventDefault(); invForm.post(route('contractors.invoices.store', contractor.ulid), { onSuccess: () => { invForm.reset(); setInvDialog(false); } }); };
const renderStars = (rating: string) => {
const r = Number(rating);
return Array.from({ length: 5 }, (_, i) => (
<Star key={i} className={`h-4 w-4 ${i < r ? 'fill-amber-400 text-amber-400' : 'text-gray-300'}`} />
));
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={route('contractors.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">{contractor.company_name}</h2>
<p className="text-sm text-gray-500">{contractor.contact_person} · {contractor.email}</p>
</div>
<Badge variant="outline">{statusLabel(contractor.status)}</Badge>
<div className="flex gap-0.5">{renderStars(contractor.rating)}</div>
</div>
<Link href={route('contractors.edit', contractor.ulid)}><Button size="sm"><Pencil className="mr-2 h-4 w-4" /> Edit</Button></Link>
</div>
}
>
<Head title={contractor.company_name} />
<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>}
<Tabs defaultValue="equipment">
<TabsList>
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="equipment">Equipment ({contractor.equipment.length})</TabsTrigger>
<TabsTrigger value="certifications">Certs ({contractor.certifications.length})</TabsTrigger>
<TabsTrigger value="invoices">Invoices ({contractor.invoices.length})</TabsTrigger>
<TabsTrigger value="projects">Projects ({contractor.projects.length})</TabsTrigger>
</TabsList>
{/* Profile Tab */}
<TabsContent value="profile">
<Card><CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div><p className="text-xs text-gray-500">Specialization</p><p className="font-medium">{contractor.specialization || '-'}</p></div>
<div><p className="text-xs text-gray-500">Payment Terms</p><p className="font-medium">{statusLabel(contractor.payment_terms)}</p></div>
<div><p className="text-xs text-gray-500">Phone</p><p>{contractor.phone || '-'}</p></div>
<div><p className="text-xs text-gray-500">Tax ID</p><p>{contractor.tax_id || '-'}</p></div>
{contractor.address && <div className="col-span-2"><p className="text-xs text-gray-500">Address</p><p>{contractor.address}</p></div>}
</div>
</CardContent></Card>
</TabsContent>
{/* Equipment Tab */}
<TabsContent value="equipment">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Equipment Registry</CardTitle>
<Dialog open={equipDialog} onOpenChange={setEquipDialog}>
<DialogTrigger render={<Button size="sm" />}><Plus className="mr-2 h-4 w-4" /> Add Equipment</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Equipment</DialogTitle></DialogHeader>
<form onSubmit={submitEquip} className="space-y-4">
<div><Label>Name *</Label><Input value={equipForm.data.name} onChange={(e) => equipForm.setData('name', e.target.value)} /></div>
<div className="grid grid-cols-2 gap-4">
<div><Label>Type</Label>
<Select value={equipForm.data.type} onValueChange={(v) => { if (v) equipForm.setData('type', v); }} items={equipTypeItems}><SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger>
<SelectContent><SelectItem value="heavy">Heavy</SelectItem><SelectItem value="light">Light</SelectItem><SelectItem value="vehicle">Vehicle</SelectItem></SelectContent>
</Select></div>
<div><Label>Daily Rate</Label><Input type="number" step="0.01" value={equipForm.data.daily_rate} onChange={(e) => equipForm.setData('daily_rate', e.target.value)} /></div>
</div>
<div><Label>Serial Number</Label><Input value={equipForm.data.serial_number} onChange={(e) => equipForm.setData('serial_number', e.target.value)} /></div>
<div className="flex justify-end"><Button type="submit" disabled={equipForm.processing}><Plus className="mr-2 h-4 w-4" /> Add</Button></div>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Type</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Daily Rate</TableHead></TableRow></TableHeader>
<TableBody>
{contractor.equipment.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center text-gray-500 py-8">No equipment registered.</TableCell></TableRow>
) : contractor.equipment.map((eq) => (
<TableRow key={eq.id}>
<TableCell className="font-medium">{eq.name}{eq.serial_number && <span className="text-xs text-gray-400 ml-2">#{eq.serial_number}</span>}</TableCell>
<TableCell><Badge variant="outline">{eq.type || '-'}</Badge></TableCell>
<TableCell><Badge variant={eq.status === 'available' ? 'default' : eq.status === 'deployed' ? 'secondary' : 'outline'}>{statusLabel(eq.status)}</Badge></TableCell>
<TableCell className="text-right">{formatCurrency(eq.daily_rate)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Certifications Tab */}
<TabsContent value="certifications">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Certifications</CardTitle>
<Dialog open={certDialog} onOpenChange={setCertDialog}>
<DialogTrigger render={<Button size="sm" />}><Plus className="mr-2 h-4 w-4" /> Add Cert</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Certification</DialogTitle></DialogHeader>
<form onSubmit={submitCert} className="space-y-4">
<div><Label>Certification Name *</Label><Input value={certForm.data.name} onChange={(e) => certForm.setData('name', e.target.value)} /></div>
<div><Label>Cert Number</Label><Input value={certForm.data.certification_number} onChange={(e) => certForm.setData('certification_number', e.target.value)} /></div>
<div className="grid grid-cols-2 gap-4">
<div><Label>Issued</Label><Input type="date" value={certForm.data.issued_date} onChange={(e) => certForm.setData('issued_date', e.target.value)} /></div>
<div><Label>Expiry</Label><Input type="date" value={certForm.data.expiry_date} onChange={(e) => certForm.setData('expiry_date', e.target.value)} /></div>
</div>
<div className="flex justify-end"><Button type="submit" disabled={certForm.processing}><Plus className="mr-2 h-4 w-4" /> Add</Button></div>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Number</TableHead><TableHead>Expiry</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{contractor.certifications.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center text-gray-500 py-8">No certifications.</TableCell></TableRow>
) : contractor.certifications.map((cert) => (
<TableRow key={cert.id}>
<TableCell className="font-medium">{cert.name}</TableCell>
<TableCell className="text-gray-500">{cert.certification_number || '-'}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{cert.expiry_date ? new Date(cert.expiry_date).toLocaleDateString() : '-'}
{isExpiringSoon(cert.expiry_date) && <AlertTriangle className="h-4 w-4 text-amber-500" />}
{isExpired(cert.expiry_date) && <AlertTriangle className="h-4 w-4 text-red-500" />}
</div>
</TableCell>
<TableCell><Badge variant={cert.status === 'active' ? 'default' : 'destructive'}>{cert.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Invoices Tab */}
<TabsContent value="invoices">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Invoices</CardTitle>
<Dialog open={invDialog} onOpenChange={setInvDialog}>
<DialogTrigger render={<Button size="sm" />}><Plus className="mr-2 h-4 w-4" /> New Invoice</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Invoice</DialogTitle></DialogHeader>
<form onSubmit={submitInv} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><Label>Invoice # *</Label><Input value={invForm.data.invoice_number} onChange={(e) => invForm.setData('invoice_number', e.target.value)} /></div>
<div><Label>Amount *</Label><Input type="number" step="0.01" value={invForm.data.amount} onChange={(e) => invForm.setData('amount', e.target.value)} /></div>
</div>
<div><Label>Project</Label>
<Select value={invForm.data.project_id} onValueChange={(v) => { if (v) invForm.setData('project_id', v); }} items={projectSelectItems}><SelectTrigger><SelectValue placeholder="Select project" /></SelectTrigger>
<SelectContent>{projects.map(p => <SelectItem key={p.id} value={p.ulid}>{p.name}</SelectItem>)}</SelectContent>
</Select></div>
<div className="grid grid-cols-2 gap-4">
<div><Label>Invoice Date *</Label><Input type="date" value={invForm.data.invoice_date} onChange={(e) => invForm.setData('invoice_date', e.target.value)} /></div>
<div><Label>Due Date</Label><Input type="date" value={invForm.data.due_date} onChange={(e) => invForm.setData('due_date', e.target.value)} /></div>
</div>
<div className="flex justify-end"><Button type="submit" disabled={invForm.processing}><Plus className="mr-2 h-4 w-4" /> Create</Button></div>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Invoice #</TableHead><TableHead>Project</TableHead><TableHead className="text-right">Amount</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead></TableRow></TableHeader>
<TableBody>
{contractor.invoices.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-gray-500 py-8">No invoices.</TableCell></TableRow>
) : contractor.invoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.invoice_number}</TableCell>
<TableCell className="text-gray-500">{inv.project?.name || '-'}</TableCell>
<TableCell className="text-right">{formatCurrency(inv.amount)}</TableCell>
<TableCell><Badge variant={invoiceStatusVariant(inv.status)}>{statusLabel(inv.status)}</Badge></TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{inv.status === 'draft' && (
<Button variant="ghost" size="icon-sm" title="Submit" onClick={() => router.patch(route('invoices.submit', inv.ulid))}>
<Send className="h-4 w-4 text-blue-500" />
</Button>
)}
{inv.status === 'submitted' && (
<Button variant="ghost" size="icon-sm" title="Approve" onClick={() => router.patch(route('invoices.approve', inv.ulid))}>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</Button>
)}
{inv.status === 'approved' && (
<Button variant="ghost" size="icon-sm" title="Record Payment" onClick={() => router.post(route('invoices.pay', inv.ulid), { amount: inv.amount, payment_method: 'bank_transfer', payment_date: new Date().toISOString().split('T')[0] })}>
<DollarSign className="h-4 w-4 text-green-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Projects Tab */}
<TabsContent value="projects">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Assigned Projects</CardTitle>
<Dialog open={projDialog} onOpenChange={setProjDialog}>
<DialogTrigger render={<Button size="sm" />}><Plus className="mr-2 h-4 w-4" /> Assign to Project</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Assign to Project</DialogTitle></DialogHeader>
<form onSubmit={submitProj} className="space-y-4">
<div><Label>Project</Label>
<Select value={projForm.data.project_id} onValueChange={(v) => { if (v) projForm.setData('project_id', v); }} items={projectSelectItems}><SelectTrigger><SelectValue placeholder="Select project" /></SelectTrigger>
<SelectContent>{projects.map(p => <SelectItem key={p.id} value={p.ulid}>{p.name} ({p.code})</SelectItem>)}</SelectContent>
</Select></div>
<div><Label>Role</Label>
<Select value={projForm.data.role} onValueChange={(v) => { if (v) projForm.setData('role', v); }} items={roleItems}><SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="subcontractor">Subcontractor</SelectItem><SelectItem value="supplier">Supplier</SelectItem><SelectItem value="consultant">Consultant</SelectItem></SelectContent>
</Select></div>
<div><Label>Contract Amount</Label><Input type="number" step="0.01" value={projForm.data.contract_amount} onChange={(e) => projForm.setData('contract_amount', e.target.value)} /></div>
<div className="flex justify-end"><Button type="submit" disabled={projForm.processing}><Plus className="mr-2 h-4 w-4" /> Assign</Button></div>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Project</TableHead><TableHead>Code</TableHead><TableHead>Role</TableHead><TableHead className="text-right">Contract Amount</TableHead><TableHead className="text-right">Actions</TableHead></TableRow></TableHeader>
<TableBody>
{contractor.projects.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-gray-500 py-8">Not assigned to any projects.</TableCell></TableRow>
) : contractor.projects.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="text-gray-500">{p.code}</TableCell>
<TableCell><Badge variant="outline">{statusLabel(p.pivot?.role || 'subcontractor')}</Badge></TableCell>
<TableCell className="text-right">{formatCurrency(p.pivot?.contract_amount || '0')}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon-sm" title="Remove" onClick={() => { if (confirm('Remove from project?')) router.delete(route('contractors.projects.remove', [contractor.ulid, p.ulid])); }}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>ContractorManagement 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-contractormanagement', 'resources/assets/sass/app.scss') }} --}}
</head>
<body>
{{ $slot }}
{{-- Vite JS --}}
{{-- {{ module_vite('build-contractormanagement', 'resources/assets/js/app.js') }} --}}
</body>
</html>

View File

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

View File

@@ -0,0 +1,87 @@
<div>
<h2>Contractor Management</h2>
@if (session()->has('message'))
<div class="alert alert-success">
{{ session('message') }}
</div>
@endif
@if (session()->has('error'))
<div class="alert alert-danger">
{{ session('error') }}
</div>
@endif
<div class="card mb-4">
<div class="card-body">
<h4>Add New Contractor</h4>
<form wire:submit.prevent="createContractor">
<div class="mb-3">
<label>Company Name</label>
<input type="text" wire:model="company_name" class="form-control">
@error('company_name') <span class="text-danger">{{ $message }}</span> @enderror
</div>
@if(auth()->user()->hasRole('admin'))
<div class="mb-3">
<label>Type</label>
<select wire:model="type" class="form-control">
<option value="main">Main Contractor</option>
<option value="sub">Sub Contractor</option>
</select>
@error('type') <span class="text-danger">{{ $message }}</span> @enderror
</div>
@else
<input type="hidden" wire:model="type" value="sub">
@endif
<div class="mb-3">
<label>User Limit</label>
<input type="number" wire:model="user_limit" class="form-control">
@error('user_limit') <span class="text-danger">{{ $message }}</span> @enderror
</div>
@if(auth()->user()->hasRole('admin'))
<div class="mb-3">
<label>Parent Contractor (for Subs)</label>
<select wire:model="parent_id" class="form-control">
<option value="">None</option>
@foreach($contractors->where('type', 'main') as $c)
<option value="{{ $c->id }}">{{ $c->company_name }}</option>
@endforeach
</select>
@error('parent_id') <span class="text-danger">{{ $message }}</span> @enderror
</div>
@endif
<button type="submit" class="btn btn-primary">Create Contractor</button>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h4>Existing Contractors</h4>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Parent</th>
<th>User Limit</th>
</tr>
</thead>
<tbody>
@foreach($contractors as $contractor)
<tr>
<td>{{ $contractor->company_name }}</td>
<td>{{ ucfirst($contractor->type) }}</td>
<td>{{ $contractor->parent ? $contractor->parent->company_name : '-' }}</td>
<td>{{ $contractor->user_limit }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\ContractorManagement\Http\Controllers\ContractorController;
use Modules\ContractorManagement\Http\Controllers\ContractorOnboardingController;
// Guest onboarding routes
Route::middleware(['web', 'guest', 'permission:contractors.access'])->group(function () {
Route::get('register/contractor', [ContractorOnboardingController::class, 'showRegistrationForm'])
->name('contractors.register');
Route::post('register/contractor', [ContractorOnboardingController::class, 'register'])
->name('contractors.register.store');
});
Route::middleware(['web', 'auth'])->group(function () {
// Admin onboarding approval routes
Route::get('contractors/pending', [ContractorOnboardingController::class, 'pending'])
->name('contractors.pending');
Route::post('contractors/{contractor}/approve', [ContractorOnboardingController::class, 'approve'])
->name('contractors.approve');
Route::post('contractors/{contractor}/reject', [ContractorOnboardingController::class, 'reject'])
->name('contractors.reject');
// Contractor CRUD
Route::resource('contractors', ContractorController::class);
// Nested actions
Route::post('contractors/{contractor}/equipment', [ContractorController::class, 'storeEquipment'])->name('contractors.equipment.store');
Route::post('contractors/{contractor}/certifications', [ContractorController::class, 'storeCertification'])->name('contractors.certifications.store');
Route::post('contractors/{contractor}/projects', [ContractorController::class, 'assignProject'])->name('contractors.projects.assign');
Route::delete('contractors/{contractor}/projects/{project}', [ContractorController::class, 'removeProject'])->name('contractors.projects.remove');
// Invoice lifecycle
Route::post('contractors/{contractor}/invoices', [ContractorController::class, 'storeInvoice'])->name('contractors.invoices.store');
Route::patch('invoices/{invoice}/submit', [ContractorController::class, 'submitInvoice'])->name('invoices.submit');
Route::patch('invoices/{invoice}/approve', [ContractorController::class, 'approveInvoice'])->name('invoices.approve');
Route::post('invoices/{invoice}/pay', [ContractorController::class, 'payInvoice'])->name('invoices.pay');
});

View File

@@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
// Uncomment the import for your frontend framework:
// import vue from '@vitejs/plugin-vue';
// import react from '@vitejs/plugin-react';
// import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
build: {
outDir: '../../public/build-contractormanagement',
emptyOutDir: true,
manifest: true,
},
plugins: [
laravel({
publicDirectory: '../../public',
buildDirectory: 'build-contractormanagement',
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',
},
},
});