chore: update document approval workflow and bug fixes
This commit is contained in:
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'));
|
||||
}
|
||||
}
|
||||
30
Modules/ContractorManagement/composer.json
Normal file
30
Modules/ContractorManagement/composer.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
Modules/ContractorManagement/config/.gitkeep
Normal file
0
Modules/ContractorManagement/config/.gitkeep
Normal file
5
Modules/ContractorManagement/config/config.php
Normal file
5
Modules/ContractorManagement/config/config.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'ContractorManagement',
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
11
Modules/ContractorManagement/module.json
Normal file
11
Modules/ContractorManagement/module.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "ContractorManagement",
|
||||
"alias": "contractormanagement",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\ContractorManagement\\Providers\\ContractorManagementServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
15
Modules/ContractorManagement/package.json
Normal file
15
Modules/ContractorManagement/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,2 @@
|
||||
import Form from './Form';
|
||||
export default Form;
|
||||
@@ -0,0 +1,2 @@
|
||||
import Form from './Form';
|
||||
export default Form;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<x-contractormanagement::layouts.master>
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>Module: {!! config('contractormanagement.name') !!}</p>
|
||||
</x-contractormanagement::layouts.master>
|
||||
@@ -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>
|
||||
0
Modules/ContractorManagement/routes/.gitkeep
Normal file
0
Modules/ContractorManagement/routes/.gitkeep
Normal file
8
Modules/ContractorManagement/routes/api.php
Normal file
8
Modules/ContractorManagement/routes/api.php
Normal 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');
|
||||
});
|
||||
39
Modules/ContractorManagement/routes/web.php
Normal file
39
Modules/ContractorManagement/routes/web.php
Normal 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');
|
||||
});
|
||||
0
Modules/ContractorManagement/tests/Feature/.gitkeep
Normal file
0
Modules/ContractorManagement/tests/Feature/.gitkeep
Normal file
0
Modules/ContractorManagement/tests/Unit/.gitkeep
Normal file
0
Modules/ContractorManagement/tests/Unit/.gitkeep
Normal file
41
Modules/ContractorManagement/vite.config.js
Normal file
41
Modules/ContractorManagement/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-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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user