Implement revised retention workflow, overdue late penalty calculation, payment proof verification, and dynamic ledger reconciliation
This commit is contained in:
@@ -112,7 +112,7 @@ class FinanceController extends Controller
|
||||
public function show(FinancialInvoice $invoice)
|
||||
{
|
||||
abort_unless($this->canAccessProject(auth()->user(), $invoice->project_id), 403);
|
||||
$invoice->load(['project:id,name,code', 'lineItems', 'retentionEntries']);
|
||||
$invoice->load(['project:id,name,code', 'lineItems', 'retentionEntries', 'paymentProofSubmittedBy:id,name,email']);
|
||||
|
||||
return Inertia::render('FinancialManagement::Invoices/Show', [
|
||||
'invoice' => $invoice,
|
||||
@@ -150,15 +150,24 @@ class FinanceController extends Controller
|
||||
return back()->with('success', 'Invoice submitted for approval.');
|
||||
}
|
||||
|
||||
private function isExecutiveApprover(?User $user): bool
|
||||
{
|
||||
if (!$user) return false;
|
||||
$userType = strtolower($user->user_type ?? '');
|
||||
if (in_array($userType, ['admin', 'super_admin', 'project_manager'], true)) {
|
||||
return true;
|
||||
}
|
||||
return $user->roles()->whereIn('name', ['Super Admin', 'admin', 'Admin', 'Project Manager', 'project_manager', 'Executive'])->exists();
|
||||
}
|
||||
|
||||
public function approve(FinancialInvoice $invoice)
|
||||
{
|
||||
$user = auth()->user();
|
||||
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
||||
$isApprover = $user->user_type === 'admin' ||
|
||||
$user->roles()->whereIn('name', ['Super Admin', 'admin'])->exists();
|
||||
$isApprover = $this->isExecutiveApprover($user);
|
||||
|
||||
if (!$isApprover) {
|
||||
return back()->with('error', 'Unauthorized. Only Admin or Super Admin can approve client invoices.');
|
||||
return back()->with('error', 'Unauthorized. Only Super Admin, Admin, or Project Manager can approve client invoices.');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -175,11 +184,10 @@ class FinanceController extends Controller
|
||||
{
|
||||
$user = auth()->user();
|
||||
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
||||
$isApprover = $user->user_type === 'admin' ||
|
||||
$user->roles()->whereIn('name', ['Super Admin', 'admin'])->exists();
|
||||
$isApprover = $this->isExecutiveApprover($user);
|
||||
|
||||
if (!$isApprover) {
|
||||
return back()->with('error', 'Unauthorized. Only Admin or Super Admin can reject client invoices.');
|
||||
return back()->with('error', 'Unauthorized. Only Super Admin, Admin, or Project Manager can reject client invoices.');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -203,6 +211,115 @@ class FinanceController extends Controller
|
||||
return back()->with('success', 'Invoice sent to client.');
|
||||
}
|
||||
|
||||
public function sendPaymentProof(Request $request, FinancialInvoice $invoice)
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
||||
abort_unless($this->isContractorAdmin($user), 403, 'Only Contractor Admin can submit payment proof.');
|
||||
|
||||
$hasFileInfo = extension_loaded('fileinfo');
|
||||
$rules = [
|
||||
'media' => 'required|file|max:10240',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
];
|
||||
if ($hasFileInfo) {
|
||||
$rules['media'] .= '|mimes:pdf,jpg,jpeg,png';
|
||||
}
|
||||
$validated = $request->validate($rules);
|
||||
$file = $request->file('media');
|
||||
|
||||
if (!$hasFileInfo) {
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, ['pdf', 'jpg', 'jpeg', 'png'], true)) {
|
||||
return back()->withErrors(['media' => 'The proof must be a file of type: pdf, jpg, jpeg, png.']);
|
||||
}
|
||||
}
|
||||
|
||||
$path = $file->store("invoice_payment_proofs/{$invoice->id}", 'public');
|
||||
|
||||
try {
|
||||
$this->billingService->submitContractorPaymentProof(
|
||||
$invoice,
|
||||
$path,
|
||||
$file->getClientOriginalName(),
|
||||
$validated['notes'] ?? null,
|
||||
$user->id
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', '10% Payment proof sent to Executive. Awaiting payment receipt confirmation.');
|
||||
}
|
||||
|
||||
public function receivePayment(Request $request, FinancialInvoice $invoice)
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
||||
$isApprover = $this->isExecutiveApprover($user);
|
||||
|
||||
if (!$isApprover) {
|
||||
return back()->with('error', 'Only Super Admin, Admin, or Project Manager can confirm received payment.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->billingService->confirmPaymentReceived($invoice, $validated['notes'] ?? null);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Payment receipt confirmed successfully. Invoice is now Paid.');
|
||||
}
|
||||
|
||||
public function viewPaymentProof(Request $request, FinancialInvoice $invoice)
|
||||
{
|
||||
abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403);
|
||||
|
||||
if (!$invoice->payment_proof_path) {
|
||||
abort(404, 'No payment proof available.');
|
||||
}
|
||||
|
||||
$disk = \Storage::disk('public');
|
||||
if (!$disk->exists($invoice->payment_proof_path)) {
|
||||
abort(404, 'Proof file not found on disk.');
|
||||
}
|
||||
|
||||
$mimeType = $disk->mimeType($invoice->payment_proof_path);
|
||||
return response()->file($disk->path($invoice->payment_proof_path), [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => 'inline; filename="' . ($invoice->payment_proof_name ?: 'payment-proof') . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function applyPenalty(Request $request, FinancialInvoice $invoice)
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
||||
abort_unless($this->isExecutiveApprover($user), 403, 'Only Super Admin, Admin, or Project Manager can assess retention penalties.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'penalty_rate' => 'required|numeric|min:0.01|max:100',
|
||||
'reason' => 'required|string|min:3|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->billingService->applyPenalty(
|
||||
$invoice,
|
||||
(float) $validated['penalty_rate'],
|
||||
$validated['reason'],
|
||||
$user->id
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', "Penalty of {$validated['penalty_rate']}% applied successfully to Invoice #{$invoice->invoice_number}.");
|
||||
}
|
||||
|
||||
public function recordPayment(Request $request, FinancialInvoice $invoice)
|
||||
{
|
||||
abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403);
|
||||
@@ -212,9 +329,7 @@ class FinanceController extends Controller
|
||||
|
||||
try {
|
||||
$user = $request->user();
|
||||
$isExecutive = is_null($user?->contractor_id)
|
||||
|| in_array($user?->user_type, ['admin', 'super_admin'])
|
||||
|| $user?->hasAnyRole(['Super Admin', 'Admin', 'Project Manager', 'Executive']);
|
||||
$isExecutive = $this->isExecutiveApprover($user);
|
||||
|
||||
if ($isExecutive) {
|
||||
$this->billingService->recordExecutivePayment($invoice, (float) $validated['amount']);
|
||||
@@ -247,6 +362,42 @@ class FinanceController extends Controller
|
||||
// --- Retention Ledger ---
|
||||
public function retention(Request $request)
|
||||
{
|
||||
// Dynamic reconciliation: Ensure every Paid invoice has its full retention + penalty settled in the ledger
|
||||
$paidInvoices = FinancialInvoice::whereIn('project_id', $this->availableProjectIdsQuery())
|
||||
->where('status', InvoiceStatus::Paid)
|
||||
->where('retention_amount', '>', 0)
|
||||
->get();
|
||||
|
||||
foreach ($paidInvoices as $paidInv) {
|
||||
$totalDue = (float) $paidInv->retention_amount + (float) ($paidInv->penalty_amount ?? 0);
|
||||
$credit = RetentionEntry::where([
|
||||
'project_id' => $paidInv->project_id,
|
||||
'invoice_id' => $paidInv->id,
|
||||
'type' => 'credit',
|
||||
])->first();
|
||||
|
||||
$desc = (float) ($paidInv->penalty_amount ?? 0) > 0
|
||||
? "Retention & Late Penalty Remittance Settled & Confirmed for Invoice #{$paidInv->invoice_number}"
|
||||
: "10% Retention Remittance Settled & Confirmed for Invoice #{$paidInv->invoice_number}";
|
||||
|
||||
if (!$credit) {
|
||||
RetentionEntry::create([
|
||||
'project_id' => $paidInv->project_id,
|
||||
'invoice_id' => $paidInv->id,
|
||||
'type' => 'credit',
|
||||
'status' => 'paid',
|
||||
'amount' => $totalDue,
|
||||
'description' => $desc,
|
||||
]);
|
||||
} elseif ((float) $credit->amount !== $totalDue) {
|
||||
$credit->update([
|
||||
'amount' => $totalDue,
|
||||
'status' => 'paid',
|
||||
'description' => $desc,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$query = RetentionEntry::with([
|
||||
'project' => fn ($query) => $query
|
||||
->withoutGlobalScopes()
|
||||
@@ -293,10 +444,56 @@ class FinanceController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// Query overdue invoices: unpaid and older than 2 months (60 days)
|
||||
$twoMonthsAgo = now()->subMonths(2);
|
||||
$overdueInvoices = FinancialInvoice::with([
|
||||
'project' => fn ($q) => $q->withoutGlobalScopes()->select('id', 'ulid', 'name', 'code'),
|
||||
'penaltyAppliedBy:id,name',
|
||||
])
|
||||
->whereIn('project_id', $this->availableProjectIdsQuery())
|
||||
->whereIn('status', [InvoiceStatus::Approved, InvoiceStatus::Sent, InvoiceStatus::PaymentSent, InvoiceStatus::Submitted])
|
||||
->where(function ($q) use ($twoMonthsAgo) {
|
||||
$q->where('invoice_date', '<=', $twoMonthsAgo)
|
||||
->orWhere('approved_at', '<=', $twoMonthsAgo)
|
||||
->orWhere('created_at', '<=', $twoMonthsAgo);
|
||||
})
|
||||
->latest('invoice_date')
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
$referenceDate = $inv->approved_at ?? $inv->invoice_date ?? $inv->created_at;
|
||||
$daysOverdue = (int) abs(now()->diffInDays($referenceDate));
|
||||
return [
|
||||
'id' => $inv->id,
|
||||
'ulid' => $inv->ulid,
|
||||
'invoice_number' => $inv->invoice_number,
|
||||
'project' => $inv->project ? [
|
||||
'id' => $inv->project->id,
|
||||
'ulid' => $inv->project->ulid,
|
||||
'name' => $inv->project->name,
|
||||
'code' => $inv->project->code,
|
||||
] : null,
|
||||
'status' => $inv->status instanceof InvoiceStatus ? $inv->status->value : $inv->status,
|
||||
'invoice_date' => $inv->invoice_date?->format('Y-m-d'),
|
||||
'approved_at' => $inv->approved_at?->format('Y-m-d H:i'),
|
||||
'subtotal' => (float) $inv->subtotal,
|
||||
'retention_amount' => (float) $inv->retention_amount,
|
||||
'retention_rate' => (float) $inv->retention_rate,
|
||||
'total_amount' => (float) $inv->total_amount,
|
||||
'paid_amount' => (float) $inv->paid_amount,
|
||||
'days_overdue' => $daysOverdue,
|
||||
'penalty_rate' => $inv->penalty_rate !== null ? (float) $inv->penalty_rate : null,
|
||||
'penalty_amount' => (float) $inv->penalty_amount,
|
||||
'penalty_reason' => $inv->penalty_reason,
|
||||
'penalty_applied_by' => $inv->penaltyAppliedBy?->name,
|
||||
'penalty_applied_at' => $inv->penalty_applied_at?->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('FinancialManagement::Retention/Index', [
|
||||
'entries' => $entries,
|
||||
'projects' => $projects,
|
||||
'projectTotals' => $projectTotals,
|
||||
'overdueInvoices' => $overdueInvoices,
|
||||
'filters' => $request->only(['project_id']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ class FinancialInvoice extends Model
|
||||
'project_id', 'invoice_number', 'status',
|
||||
'subtotal', 'retention_amount', 'total_amount', 'paid_amount',
|
||||
'retention_rate', 'billed_percentage',
|
||||
'penalty_rate', 'penalty_amount', 'penalty_reason',
|
||||
'penalty_applied_by', 'penalty_applied_at',
|
||||
'invoice_date', 'due_date', 'notes',
|
||||
'payment_proof_path', 'payment_proof_name', 'payment_proof_notes',
|
||||
'payment_proof_submitted_by', 'payment_proof_submitted_at',
|
||||
'submitted_at', 'approved_at', 'sent_at', 'paid_at',
|
||||
];
|
||||
|
||||
@@ -36,8 +40,12 @@ class FinancialInvoice extends Model
|
||||
'paid_amount' => 'decimal:2',
|
||||
'retention_rate' => 'decimal:2',
|
||||
'billed_percentage' => 'decimal:2',
|
||||
'penalty_rate' => 'decimal:2',
|
||||
'penalty_amount' => 'decimal:2',
|
||||
'penalty_applied_at' => 'datetime',
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'payment_proof_submitted_at' => 'datetime',
|
||||
'submitted_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
@@ -45,6 +53,51 @@ class FinancialInvoice extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(function (FinancialInvoice $invoice) {
|
||||
if ($invoice->status === InvoiceStatus::Paid && (float) $invoice->retention_amount > 0) {
|
||||
$totalDue = (float) $invoice->retention_amount + (float) ($invoice->penalty_amount ?? 0);
|
||||
$credit = RetentionEntry::where([
|
||||
'project_id' => $invoice->project_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'credit',
|
||||
])->first();
|
||||
|
||||
$desc = (float) ($invoice->penalty_amount ?? 0) > 0
|
||||
? "Retention & Late Penalty Remittance Settled & Confirmed for Invoice #{$invoice->invoice_number}"
|
||||
: "10% Retention Remittance Settled & Confirmed for Invoice #{$invoice->invoice_number}";
|
||||
|
||||
if (!$credit) {
|
||||
RetentionEntry::create([
|
||||
'project_id' => $invoice->project_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'credit',
|
||||
'status' => 'paid',
|
||||
'amount' => $totalDue,
|
||||
'description' => $desc,
|
||||
]);
|
||||
} elseif ((float) $credit->amount !== $totalDue) {
|
||||
$credit->update([
|
||||
'amount' => $totalDue,
|
||||
'status' => 'paid',
|
||||
'description' => $desc,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function penaltyAppliedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'penalty_applied_by');
|
||||
}
|
||||
|
||||
public function paymentProofSubmittedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'payment_proof_submitted_by');
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -78,6 +78,131 @@ class ProgressBillingService
|
||||
/**
|
||||
* On invoice payment, update project's last billed percentage.
|
||||
*/
|
||||
/**
|
||||
* Contractor Admin submits 10% payment proof for approved invoice.
|
||||
*/
|
||||
public function submitContractorPaymentProof(
|
||||
FinancialInvoice $invoice,
|
||||
string $mediaPath,
|
||||
string $mediaName,
|
||||
?string $notes = null,
|
||||
?int $userId = null
|
||||
): void {
|
||||
$invoice->update([
|
||||
'status' => InvoiceStatus::PaymentSent,
|
||||
'payment_proof_path' => $mediaPath,
|
||||
'payment_proof_name' => $mediaName,
|
||||
'payment_proof_notes' => $notes,
|
||||
'payment_proof_submitted_by' => $userId ?? auth()->id(),
|
||||
'payment_proof_submitted_at' => now(),
|
||||
'paid_amount' => $invoice->total_amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executive confirms payment received (transitions invoice to Paid & updates project progress).
|
||||
*/
|
||||
public function confirmPaymentReceived(FinancialInvoice $invoice, ?string $notes = null): void
|
||||
{
|
||||
$totalAmount = (float) $invoice->total_amount;
|
||||
|
||||
$invoice->status = InvoiceStatus::Paid;
|
||||
$invoice->paid_amount = $totalAmount;
|
||||
$invoice->paid_at = now();
|
||||
if ($notes) {
|
||||
$invoice->notes = ($invoice->notes ? $invoice->notes . "\n" : "") . "Executive confirmation: " . $notes;
|
||||
}
|
||||
$invoice->save();
|
||||
|
||||
if (\Schema::hasColumn('projects', 'completion_percentage')) {
|
||||
$invoice->project()->update(['completion_percentage' => $invoice->billed_percentage]);
|
||||
}
|
||||
|
||||
// Settle retention entry in ledger (both base retention and any assessed penalty) so balance becomes 0
|
||||
$totalRetentionToSettle = (float) $invoice->retention_amount + (float) ($invoice->penalty_amount ?? 0);
|
||||
if ($totalRetentionToSettle > 0) {
|
||||
$desc = (float) ($invoice->penalty_amount ?? 0) > 0
|
||||
? "Retention & Late Penalty Remittance Settled & Confirmed for Invoice #{$invoice->invoice_number}"
|
||||
: "10% Retention Remittance Settled & Confirmed for Invoice #{$invoice->invoice_number}";
|
||||
|
||||
$creditEntry = RetentionEntry::where([
|
||||
'project_id' => $invoice->project_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'credit',
|
||||
])->first();
|
||||
|
||||
if ($creditEntry) {
|
||||
$creditEntry->update([
|
||||
'amount' => $totalRetentionToSettle,
|
||||
'status' => 'paid',
|
||||
'description' => $desc,
|
||||
]);
|
||||
} else {
|
||||
RetentionEntry::create([
|
||||
'project_id' => $invoice->project_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'credit',
|
||||
'status' => 'paid',
|
||||
'amount' => $totalRetentionToSettle,
|
||||
'description' => $desc,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executive applies percentage-based penalty on overdue retention / invoice.
|
||||
*/
|
||||
public function applyPenalty(
|
||||
FinancialInvoice $invoice,
|
||||
float $penaltyRate,
|
||||
string $reason,
|
||||
?int $userId = null
|
||||
): void {
|
||||
if ($penaltyRate <= 0 || $penaltyRate > 100) {
|
||||
throw new \InvalidArgumentException("Penalty rate must be between 0.01% and 100%.");
|
||||
}
|
||||
|
||||
// Base calculation on retention amount (or invoice subtotal if 0)
|
||||
$baseAmount = (float) $invoice->retention_amount > 0 ? (float) $invoice->retention_amount : (float) $invoice->subtotal;
|
||||
$penaltyCost = round(($baseAmount * ($penaltyRate / 100)), 2);
|
||||
|
||||
$invoice->update([
|
||||
'penalty_rate' => $penaltyRate,
|
||||
'penalty_amount' => $penaltyCost,
|
||||
'penalty_reason' => $reason,
|
||||
'penalty_applied_by' => $userId ?? auth()->id(),
|
||||
'penalty_applied_at' => now(),
|
||||
]);
|
||||
|
||||
// Record a penalty debit adjustment in retention ledger
|
||||
RetentionEntry::create([
|
||||
'project_id' => $invoice->project_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'debit',
|
||||
'status' => 'posted',
|
||||
'amount' => $penaltyCost,
|
||||
'description' => "Late Remittance Penalty ({$penaltyRate}%) assessed on Invoice #{$invoice->invoice_number}: {$reason}",
|
||||
'submitted_by' => $userId ?? auth()->id(),
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
// If invoice is already paid/settled, adjust existing credit entry to maintain net 0 balance
|
||||
if ($invoice->status === InvoiceStatus::Paid) {
|
||||
$creditEntry = RetentionEntry::where([
|
||||
'project_id' => $invoice->project_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'credit',
|
||||
])->first();
|
||||
|
||||
if ($creditEntry) {
|
||||
$creditEntry->update([
|
||||
'amount' => (float) $invoice->retention_amount + $penaltyCost,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executive releases/records payment (transitions invoice to PaymentSent pending contractor confirmation).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('financial_invoices', function (Blueprint $table) {
|
||||
$table->string('payment_proof_path')->nullable()->after('notes');
|
||||
$table->string('payment_proof_name')->nullable()->after('payment_proof_path');
|
||||
$table->text('payment_proof_notes')->nullable()->after('payment_proof_name');
|
||||
$table->foreignId('payment_proof_submitted_by')->nullable()->after('payment_proof_notes')->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('payment_proof_submitted_at')->nullable()->after('payment_proof_submitted_by');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('financial_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['payment_proof_submitted_by']);
|
||||
$table->dropColumn([
|
||||
'payment_proof_path',
|
||||
'payment_proof_name',
|
||||
'payment_proof_notes',
|
||||
'payment_proof_submitted_by',
|
||||
'payment_proof_submitted_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('financial_invoices', function (Blueprint $table) {
|
||||
$table->decimal('penalty_rate', 5, 2)->nullable()->after('retention_rate');
|
||||
$table->decimal('penalty_amount', 15, 2)->default(0)->after('penalty_rate');
|
||||
$table->text('penalty_reason')->nullable()->after('penalty_amount');
|
||||
$table->foreignId('penalty_applied_by')->nullable()->after('penalty_reason')->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('penalty_applied_at')->nullable()->after('penalty_applied_by');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('financial_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['penalty_applied_by']);
|
||||
$table->dropColumn([
|
||||
'penalty_rate',
|
||||
'penalty_amount',
|
||||
'penalty_reason',
|
||||
'penalty_applied_by',
|
||||
'penalty_applied_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Textarea } from '@/Components/ui/textarea';
|
||||
import { AlertCircle, FileCheck, Loader2, UploadCloud, X } from 'lucide-react';
|
||||
import { FormEvent, useRef, useState } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
invoice: {
|
||||
ulid: string;
|
||||
invoice_number: string;
|
||||
total_amount: string | number;
|
||||
retention_amount?: string | number;
|
||||
penalty_amount?: string | number;
|
||||
penalty_rate?: string | number;
|
||||
retention_rate?: string | number;
|
||||
project?: { name: string; code: string };
|
||||
} | null;
|
||||
}
|
||||
|
||||
const formatCurrency = (v: string | number) =>
|
||||
new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v || 0));
|
||||
|
||||
export default function RetentionPaymentProofModal({ isOpen, onClose, invoice }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!invoice) return null;
|
||||
|
||||
const hasPenalty = Number(invoice.penalty_amount || 0) > 0;
|
||||
const totalRetentionDue = Number(invoice.retention_amount || 0) + Number(invoice.penalty_amount || 0);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const validTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
|
||||
if (!validTypes.includes(file.type) && !file.name.match(/\.(pdf|jpe?g|png)$/i)) {
|
||||
setError('Please select a valid document (PDF, JPG, PNG).');
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('File size must not exceed 10MB.');
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile) {
|
||||
setError('Please upload a payment proof receipt (PDF or Image).');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('media', selectedFile);
|
||||
if (notes.trim()) {
|
||||
formData.append('notes', notes.trim());
|
||||
}
|
||||
|
||||
router.post(route('finance.send-payment-proof', invoice.ulid), formData, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
setSelectedFile(null);
|
||||
setNotes('');
|
||||
setProcessing(false);
|
||||
onClose();
|
||||
},
|
||||
onError: (errors) => {
|
||||
setProcessing(false);
|
||||
setError(errors.media || errors.notes || 'Failed to upload payment proof.');
|
||||
},
|
||||
onFinish: () => {
|
||||
setProcessing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px] bg-white shadow-2xl border-slate-200">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<UploadCloud className="h-5 w-5 text-indigo-600" />
|
||||
Send Payment Proof
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-slate-500">
|
||||
Upload your payment evidence/receipt to submit to Executive for receipt confirmation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Invoice Summary Box */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-3.5 space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center text-slate-600">
|
||||
<span>Invoice Number:</span>
|
||||
<span className="font-semibold text-slate-900 font-mono">{invoice.invoice_number}</span>
|
||||
</div>
|
||||
{invoice.project && (
|
||||
<div className="flex justify-between items-center text-slate-600">
|
||||
<span>Project:</span>
|
||||
<span className="font-medium text-slate-800">{invoice.project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-slate-600">
|
||||
<span>Total Invoice Amount:</span>
|
||||
<span className="font-semibold text-slate-900">{formatCurrency(invoice.total_amount)}</span>
|
||||
</div>
|
||||
{invoice.retention_amount !== undefined && (
|
||||
<div className="flex justify-between items-center pt-1.5 border-t border-slate-200 text-slate-900 font-bold">
|
||||
<span>{hasPenalty ? 'Total Retention & Penalty Due:' : '10% Retention Remittance:'}</span>
|
||||
<span className="text-rose-600 font-mono">-{formatCurrency(totalRetentionDue)}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasPenalty && (
|
||||
<div className="flex justify-between items-center text-[11px] text-slate-500 font-mono">
|
||||
<span>Breakdown (Base + {invoice.penalty_rate}% Penalty):</span>
|
||||
<span>{formatCurrency(invoice.retention_amount || 0)} + {formatCurrency(invoice.penalty_amount || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700 flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload Zone */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-slate-700">Payment Receipt / Deposit Slip *</Label>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-5 text-center cursor-pointer transition-all ${
|
||||
selectedFile
|
||||
? 'border-indigo-400 bg-indigo-50/50'
|
||||
: 'border-slate-300 hover:border-indigo-400 bg-slate-50/50 hover:bg-indigo-50/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,image/png,image/jpeg,image/jpg"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2.5 text-left">
|
||||
<div className="p-2 bg-indigo-100 text-indigo-700 rounded-lg">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-800 line-clamp-1">{selectedFile.name}</p>
|
||||
<p className="text-[10px] text-slate-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}}
|
||||
className="text-slate-400 hover:text-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="mx-auto w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center text-indigo-600">
|
||||
<UploadCloud className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-700">
|
||||
Click to browse or drag payment proof receipt
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">PDF, PNG, JPG up to 10MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference / Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proof-notes" className="text-xs font-semibold text-slate-700">
|
||||
Bank Reference / Transaction Notes (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="proof-notes"
|
||||
placeholder="e.g. Bank Transfer Ref: BT-981244, Deposited via BDO Online Banking"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2 flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose} disabled={processing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold shadow-xs"
|
||||
disabled={processing || !selectedFile}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="mr-2 h-4 w-4" />
|
||||
Submit Payment Proof
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Textarea } from '@/Components/ui/textarea';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { AlertCircle, Calculator, ShieldAlert, Sparkles, Clock } from 'lucide-react';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export interface OverdueInvoiceItem {
|
||||
id: number;
|
||||
ulid?: string;
|
||||
invoice_number: string;
|
||||
project: {
|
||||
id: number;
|
||||
ulid: string;
|
||||
name: string;
|
||||
code: string;
|
||||
} | null;
|
||||
status: string;
|
||||
invoice_date: string;
|
||||
approved_at?: string;
|
||||
subtotal: number;
|
||||
retention_amount: number;
|
||||
retention_rate: number;
|
||||
total_amount: number;
|
||||
paid_amount: number;
|
||||
days_overdue: number;
|
||||
penalty_rate: number | null;
|
||||
penalty_amount: number;
|
||||
penalty_reason: string | null;
|
||||
penalty_applied_by: string | null;
|
||||
penalty_applied_at: string | null;
|
||||
}
|
||||
|
||||
interface RetentionPenaltyModalProps {
|
||||
invoice: OverdueInvoiceItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isExecutive: boolean;
|
||||
}
|
||||
|
||||
const PRESET_RATES = [2, 5, 10, 15, 20];
|
||||
|
||||
export default function RetentionPenaltyModal({
|
||||
invoice,
|
||||
isOpen,
|
||||
onClose,
|
||||
isExecutive,
|
||||
}: RetentionPenaltyModalProps) {
|
||||
if (!invoice) return null;
|
||||
|
||||
const [customRate, setCustomRate] = useState<string>(
|
||||
invoice.penalty_rate ? String(invoice.penalty_rate) : '5'
|
||||
);
|
||||
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
penalty_rate: invoice.penalty_rate ? String(invoice.penalty_rate) : '5',
|
||||
reason: invoice.penalty_reason || '',
|
||||
});
|
||||
|
||||
const numericRate = parseFloat(data.penalty_rate) || 0;
|
||||
const baseRetention = Number(invoice.retention_amount) > 0 ? Number(invoice.retention_amount) : Number(invoice.subtotal);
|
||||
const computedPenalty = useMemo(() => {
|
||||
return Math.round(baseRetention * (numericRate / 100) * 100) / 100;
|
||||
}, [baseRetention, numericRate]);
|
||||
|
||||
const handlePresetSelect = (rate: number) => {
|
||||
setData('penalty_rate', String(rate));
|
||||
setCustomRate(String(rate));
|
||||
};
|
||||
|
||||
const handleRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setCustomRate(val);
|
||||
setData('penalty_rate', val);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const targetId = invoice.ulid || invoice.id;
|
||||
post(route('finance.apply-penalty', targetId), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isAlreadyPenalized = invoice.penalty_rate !== null && Number(invoice.penalty_amount) > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-xl p-0 overflow-hidden border border-slate-200 shadow-2xl">
|
||||
<div className="bg-gradient-to-r from-red-600 via-rose-600 to-amber-600 p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-white/15 backdrop-blur-xs rounded-xl">
|
||||
<ShieldAlert className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold text-white tracking-tight">
|
||||
{isAlreadyPenalized ? 'Update Retention Penalty' : 'Assess Retention Penalty'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-rose-100 text-xs mt-0.5">
|
||||
Overdue 10% retention remittance penalty configuration
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-white/20 hover:bg-white/30 text-white border-0 font-mono text-xs px-2.5 py-1">
|
||||
<Clock className="w-3.5 h-3.5 mr-1 text-amber-200" />
|
||||
{invoice.days_overdue} Days Overdue
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{/* Invoice & Project Context Card */}
|
||||
<div className="bg-slate-50 border border-slate-200/80 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Project</span>
|
||||
<h4 className="text-sm font-bold text-slate-800">{invoice.project?.name || 'N/A'}</h4>
|
||||
<span className="text-xs text-slate-500 font-mono">Invoice #{invoice.invoice_number}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700 font-semibold text-xs">
|
||||
Overdue {'>'} 2 Months
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-slate-200/60 text-xs">
|
||||
<div>
|
||||
<span className="text-slate-400 text-[10px] block">Invoice Subtotal</span>
|
||||
<span className="font-semibold text-slate-700 font-mono">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-[10px] block">10% Retention</span>
|
||||
<span className="font-bold text-rose-600 font-mono">{formatCurrency(invoice.retention_amount)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-[10px] block">Invoice Date</span>
|
||||
<span className="font-medium text-slate-700">{invoice.invoice_date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAlreadyPenalized && (
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800 space-y-1">
|
||||
<div className="flex items-center gap-1.5 font-bold">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600" />
|
||||
Current Penalty Status
|
||||
</div>
|
||||
<p>
|
||||
A penalty of <strong>{invoice.penalty_rate}%</strong> ({formatCurrency(invoice.penalty_amount)}) was previously assessed
|
||||
{invoice.penalty_applied_by ? ` by ${invoice.penalty_applied_by}` : ''}
|
||||
{invoice.penalty_applied_at ? ` on ${invoice.penalty_applied_at}` : ''}.
|
||||
</p>
|
||||
{invoice.penalty_reason && (
|
||||
<p className="text-slate-600 italic">"{invoice.penalty_reason}"</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Penalty Rate Configuration */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="penalty_rate" className="text-xs font-bold uppercase tracking-wider text-slate-600 flex items-center gap-1.5">
|
||||
<Calculator className="w-3.5 h-3.5 text-slate-500" /> Penalty Percentage Rate (%)
|
||||
</Label>
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
Presets:
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="flex items-center gap-2">
|
||||
{PRESET_RATES.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
type="button"
|
||||
onClick={() => handlePresetSelect(rate)}
|
||||
className={`flex-1 py-1.5 text-xs font-bold rounded-lg border transition-all ${
|
||||
numericRate === rate
|
||||
? 'bg-rose-600 text-white border-rose-600 shadow-xs'
|
||||
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{rate}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Percentage Input */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="penalty_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max="100"
|
||||
value={data.penalty_rate}
|
||||
onChange={handleRateChange}
|
||||
placeholder="Enter custom percentage (e.g. 5)"
|
||||
className="font-mono pr-8 font-semibold text-slate-800"
|
||||
disabled={!isExecutive}
|
||||
/>
|
||||
<span className="absolute right-3 top-2.5 text-xs font-bold text-slate-400 pointer-events-none">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
{errors.penalty_rate && (
|
||||
<p className="text-xs text-red-600 font-medium">{errors.penalty_rate}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Computed Live Cost Summary Banner */}
|
||||
<div className="bg-gradient-to-r from-red-50 to-rose-50 border border-rose-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[11px] font-bold text-rose-800 uppercase tracking-wider block">
|
||||
Computed Penalty Amount ({numericRate}%)
|
||||
</span>
|
||||
<span className="text-xs text-rose-600">
|
||||
Calculated on 10% retention base ({formatCurrency(baseRetention)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-black text-rose-700 font-mono tracking-tight">
|
||||
{formatCurrency(computedPenalty)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Penalty Reason Textarea */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reason" className="text-xs font-bold uppercase tracking-wider text-slate-600">
|
||||
Reason / Justification <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
rows={3}
|
||||
value={data.reason}
|
||||
onChange={(e) => setData('reason', e.target.value)}
|
||||
placeholder="e.g. Invoice overdue beyond 60-day remittance grace period. Applying standard 5% overdue penalty."
|
||||
className="text-xs text-slate-800"
|
||||
disabled={!isExecutive}
|
||||
/>
|
||||
{errors.reason && (
|
||||
<p className="text-xs text-red-600 font-medium">{errors.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2 border-t border-slate-100 flex items-center justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="border-slate-200 text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isExecutive ? (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || numericRate <= 0 || !data.reason.trim()}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white font-bold shadow-xs px-5"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-1.5" />
|
||||
{processing ? 'Applying...' : isAlreadyPenalized ? 'Update Penalty' : 'Assess & Apply Penalty'}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
Read-only (Super Admin / Admin / PM required to apply penalty)
|
||||
</span>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { usePage, Link } from '@inertiajs/react';
|
||||
import { PageProps, RetentionReminderItem } from '@/types';
|
||||
import { useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, ChevronRight, DollarSign, Send, UploadCloud, X } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import RetentionPaymentProofModal from './RetentionPaymentProofModal';
|
||||
|
||||
const formatCurrency = (v: string | number) =>
|
||||
new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v || 0));
|
||||
|
||||
export default function RetentionStickyToast() {
|
||||
const { props } = usePage<PageProps>();
|
||||
const reminders = props.retention_reminders;
|
||||
const [dismissed, setDismissed] = useState<string[]>([]);
|
||||
const [activeProofModalInvoice, setActiveProofModalInvoice] = useState<RetentionReminderItem | null>(null);
|
||||
|
||||
if (!reminders || reminders.count === 0) return null;
|
||||
|
||||
const visibleItems = reminders.items.filter((item) => !dismissed.includes(item.id));
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
const currentItem = visibleItems[0];
|
||||
const isContractor = currentItem.role_target === 'contractor';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-6 right-6 z-50 max-w-md w-full animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||
<div
|
||||
className={`rounded-2xl border shadow-2xl p-4 backdrop-blur-md transition-all ${
|
||||
isContractor
|
||||
? 'bg-amber-50/95 border-amber-300/80 text-amber-950 shadow-amber-500/10'
|
||||
: 'bg-indigo-50/95 border-indigo-300/80 text-indigo-950 shadow-indigo-500/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`p-2.5 rounded-xl shrink-0 mt-0.5 ${
|
||||
isContractor ? 'bg-amber-200/80 text-amber-800' : 'bg-indigo-200/80 text-indigo-800'
|
||||
}`}
|
||||
>
|
||||
{isContractor ? (
|
||||
<Send className="h-5 w-5 animate-pulse" />
|
||||
) : (
|
||||
<DollarSign className="h-5 w-5 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-xs uppercase tracking-wider">
|
||||
{currentItem.title}
|
||||
</h4>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 font-bold uppercase ${
|
||||
isContractor
|
||||
? 'bg-amber-100 text-amber-900 border-amber-300'
|
||||
: 'bg-indigo-100 text-indigo-900 border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
{isContractor ? 'Awaiting 10% Proof' : 'Proof Received'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed font-medium">
|
||||
{currentItem.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1 text-[11px] font-semibold text-slate-600">
|
||||
<span>Amount:</span>
|
||||
<span className="font-bold text-slate-900 font-mono">
|
||||
{formatCurrency(currentItem.amount)}
|
||||
</span>
|
||||
{currentItem.retention_amount && (
|
||||
<span className={`font-mono font-bold ${isContractor ? 'text-rose-700' : 'text-emerald-700'}`}>
|
||||
(10% Ret: {isContractor ? '-' : '+'}{formatCurrency(currentItem.retention_amount)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setDismissed((prev) => [...prev, currentItem.id])}
|
||||
className="text-slate-400 hover:text-slate-700 shrink-0 -mt-1 -mr-1"
|
||||
title="Dismiss reminder"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="mt-3.5 pt-3 border-t border-slate-200/80 flex items-center justify-between gap-2">
|
||||
{visibleItems.length > 1 ? (
|
||||
<span className="text-[10px] text-slate-500 font-medium">
|
||||
+{visibleItems.length - 1} more pending action{visibleItems.length > 2 ? 's' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 font-medium">
|
||||
Project: {currentItem.project_name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isContractor ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setActiveProofModalInvoice(currentItem)}
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white font-semibold text-xs shadow-xs"
|
||||
>
|
||||
<UploadCloud className="mr-1.5 h-3.5 w-3.5" /> Send Payment Proof
|
||||
</Button>
|
||||
) : (
|
||||
<Link href={currentItem.view_url}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs shadow-xs"
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" /> Check & Confirm
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link href={currentItem.view_url}>
|
||||
<Button variant="outline" size="sm" className="text-xs bg-white">
|
||||
Details <ChevronRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-Action Modal for Contractor Admin */}
|
||||
{activeProofModalInvoice && (
|
||||
<RetentionPaymentProofModal
|
||||
isOpen={!!activeProofModalInvoice}
|
||||
onClose={() => setActiveProofModalInvoice(null)}
|
||||
invoice={{
|
||||
ulid: activeProofModalInvoice.ulid,
|
||||
invoice_number: activeProofModalInvoice.invoice_number,
|
||||
total_amount: activeProofModalInvoice.amount,
|
||||
retention_amount: activeProofModalInvoice.retention_amount,
|
||||
retention_rate: activeProofModalInvoice.retention_rate || 10,
|
||||
project: {
|
||||
name: activeProofModalInvoice.project_name,
|
||||
code: '',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,15 @@ 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 { FileText, Plus, DollarSign, AlertTriangle, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { FileText, Plus, DollarSign, AlertTriangle, TrendingUp, Wallet, UploadCloud, CheckCircle2 } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import RetentionPaymentProofModal from '../../Components/RetentionPaymentProofModal';
|
||||
|
||||
interface InvoiceItem {
|
||||
id: number; ulid: string; invoice_number: string; status: string;
|
||||
subtotal: string; total_amount: string; paid_amount: string; retention_amount: string;
|
||||
billed_percentage: string; invoice_date: string; due_date?: string;
|
||||
retention_rate?: string;
|
||||
project?: { id: number; ulid: string; name: string; code: string } | null;
|
||||
}
|
||||
|
||||
@@ -29,18 +31,19 @@ const formatCurrency = (v: string | number) => new Intl.NumberFormat('en-PH', {
|
||||
const statusConfig: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string }> = {
|
||||
draft: { variant: 'outline', label: 'Draft' },
|
||||
submitted: { variant: 'secondary', label: 'Submitted' },
|
||||
approved: { variant: 'default', label: 'Approved' },
|
||||
approved: { variant: 'default', label: 'Approved (Awaiting 10% Proof)' },
|
||||
rejected: { variant: 'destructive', label: 'Rejected' },
|
||||
sent: { variant: 'secondary', label: 'Sent' },
|
||||
payment_sent: { variant: 'secondary', label: 'Payment Sent (Pending Confirmation)' },
|
||||
sent: { variant: 'secondary', label: 'Sent (Awaiting 10% Proof)' },
|
||||
payment_sent: { variant: 'secondary', label: 'Proof Sent (Pending Receipt)' },
|
||||
partially_paid: { variant: 'outline', label: 'Partially Paid' },
|
||||
paid: { variant: 'default', label: 'Paid' },
|
||||
paid: { variant: 'default', label: 'Paid & Verified' },
|
||||
overdue: { variant: 'destructive', label: 'Overdue' },
|
||||
};
|
||||
|
||||
export default function Index({ invoices, projects, summary, filters }: Props) {
|
||||
const { flash, auth } = usePage<PageProps>().props;
|
||||
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
|
||||
const [proofModalInvoice, setProofModalInvoice] = useState<InvoiceItem | null>(null);
|
||||
|
||||
// Items arrays for Select label lookup
|
||||
const statusFilterItems = useMemo(() => [{ value: 'all', label: 'All Statuses' }, ...Object.entries(statusConfig).map(([key, cfg]) => ({ value: key, label: cfg.label }))], []);
|
||||
@@ -66,7 +69,7 @@ export default function Index({ invoices, projects, summary, filters }: Props) {
|
||||
{flash?.error && <div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><DollarSign className="h-8 w-8 text-green-500" /><div><p className="text-xs text-gray-500">Total Billed</p><p className="text-lg font-bold">{formatCurrency(summary.total_billed)}</p></div></div></CardContent></Card>
|
||||
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><TrendingUp className="h-8 w-8 text-blue-500" /><div><p className="text-xs text-gray-500">Total Paid</p><p className="text-lg font-bold">{formatCurrency(summary.total_paid)}</p></div></div></CardContent></Card>
|
||||
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><AlertTriangle className="h-8 w-8 text-amber-500" /><div><p className="text-xs text-gray-500">Outstanding</p><p className="text-lg font-bold">{formatCurrency(summary.outstanding)}</p></div></div></CardContent></Card>
|
||||
@@ -94,12 +97,12 @@ export default function Index({ invoices, projects, summary, filters }: Props) {
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Invoice #</TableHead><TableHead>Project</TableHead><TableHead>Progress</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead><TableHead className="text-right">Retention</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead><TableHead className="text-right">Retention (10%)</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead><TableHead>Status</TableHead><TableHead>Due</TableHead><TableHead className="text-right">Action</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{invoices.data.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-gray-500 py-8">No invoices.</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-gray-500 py-8">No invoices found.</TableCell></TableRow>
|
||||
) : invoices.data.map((inv) => {
|
||||
const cfg = statusConfig[inv.status] || statusConfig.draft;
|
||||
const isOverdue = inv.due_date && new Date(inv.due_date) < new Date() && inv.status !== 'paid';
|
||||
@@ -118,28 +121,40 @@ export default function Index({ invoices, projects, summary, filters }: Props) {
|
||||
);
|
||||
return (
|
||||
<TableRow key={inv.id} className={isOverdue ? 'bg-red-50' : ''}>
|
||||
<TableCell><Link href={route('finance.show', inv.ulid)} className="font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link></TableCell>
|
||||
<TableCell><Link href={route('finance.show', inv.ulid)} className="font-semibold text-indigo-600 hover:underline">{inv.invoice_number}</Link></TableCell>
|
||||
<TableCell className="text-gray-500">{inv.project?.name || 'Project unavailable'}</TableCell>
|
||||
<TableCell>{Number(inv.billed_percentage).toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(inv.subtotal)}</TableCell>
|
||||
<TableCell className="text-right text-gray-500">{formatCurrency(inv.retention_amount)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatCurrency(inv.total_amount)}</TableCell>
|
||||
<TableCell className={`text-right font-medium font-mono ${isContractorAdmin ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||
{isContractorAdmin ? '-' : '+'}{formatCurrency(inv.retention_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-slate-900">{formatCurrency(inv.total_amount)}</TableCell>
|
||||
<TableCell><Badge variant={cfg.variant}>{cfg.label}</Badge>{isOverdue && inv.status !== 'overdue' && <AlertTriangle className="inline ml-1 h-4 w-4 text-red-500" />}</TableCell>
|
||||
<TableCell className="text-gray-500">{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{inv.status === 'payment_sent' && isContractorAdmin ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs h-7 px-2.5 shadow-sm"
|
||||
onClick={() => router.patch(route('finance.confirm-payment', inv.ulid))}
|
||||
>
|
||||
Confirm Receipt
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{(inv.status === 'approved' || inv.status === 'sent') && isContractorAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white text-xs h-7 px-2.5 shadow-xs"
|
||||
onClick={() => setProofModalInvoice(inv)}
|
||||
>
|
||||
<UploadCloud className="mr-1 h-3 w-3" /> Send Proof
|
||||
</Button>
|
||||
)}
|
||||
{inv.status === 'payment_sent' && isExecutive && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-xs h-7 px-2.5 shadow-xs"
|
||||
onClick={() => router.patch(route('finance.receive-payment', inv.ulid))}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" /> Received Payment
|
||||
</Button>
|
||||
)}
|
||||
<Link href={route('finance.show', inv.ulid)}>
|
||||
<Button size="sm" variant="ghost" className="text-xs h-7 px-2">View</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -158,6 +173,25 @@ export default function Index({ invoices, projects, summary, filters }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div></div>
|
||||
|
||||
{/* Contractor 10% Payment Proof Modal */}
|
||||
{proofModalInvoice && (
|
||||
<RetentionPaymentProofModal
|
||||
isOpen={!!proofModalInvoice}
|
||||
onClose={() => setProofModalInvoice(null)}
|
||||
invoice={{
|
||||
ulid: proofModalInvoice.ulid,
|
||||
invoice_number: proofModalInvoice.invoice_number,
|
||||
total_amount: proofModalInvoice.total_amount,
|
||||
retention_amount: proofModalInvoice.retention_amount,
|
||||
retention_rate: proofModalInvoice.retention_rate,
|
||||
project: proofModalInvoice.project ? {
|
||||
name: proofModalInvoice.project.name,
|
||||
code: proofModalInvoice.project.code,
|
||||
} : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
|
||||
import { ArrowLeft, Send, CheckCircle2, XCircle, MailCheck, DollarSign } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/Components/ui/dialog';
|
||||
import { ArrowLeft, Send, CheckCircle2, XCircle, DollarSign, UploadCloud, Eye, FileText, Clock } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import RetentionPaymentProofModal from '../../Components/RetentionPaymentProofModal';
|
||||
|
||||
interface LineItem { id: number; ulid: string; description: string; quantity: string; unit_price: string; total: string }
|
||||
interface RetEntry { id: number; ulid: string; type: string; amount: string; description?: string; created_at: string }
|
||||
@@ -17,7 +16,13 @@ interface InvoiceData {
|
||||
id: number; ulid: string; invoice_number: string; status: string;
|
||||
subtotal: string; retention_amount: string; total_amount: string; paid_amount: string;
|
||||
retention_rate: string; billed_percentage: string;
|
||||
penalty_rate?: string | number; penalty_amount?: string | number; penalty_reason?: string | null; penalty_applied_at?: string | null;
|
||||
invoice_date: string; due_date?: string; notes?: string;
|
||||
payment_proof_path?: string | null;
|
||||
payment_proof_name?: string | null;
|
||||
payment_proof_notes?: string | null;
|
||||
payment_proof_submitted_at?: string | null;
|
||||
payment_proof_submitted_by?: { id: number; name: string; email: string } | null;
|
||||
submitted_at?: string; approved_at?: string; sent_at?: string; paid_at?: string;
|
||||
project: { id: number; ulid: string; name: string; code: string };
|
||||
line_items: LineItem[];
|
||||
@@ -29,51 +34,58 @@ interface Props extends PageProps { invoice: InvoiceData }
|
||||
const formatCurrency = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
||||
|
||||
const statusConfig: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string }> = {
|
||||
draft: { variant: 'outline', label: 'Draft' }, submitted: { variant: 'secondary', label: 'Submitted' },
|
||||
approved: { variant: 'default', label: 'Approved' }, rejected: { variant: 'destructive', label: 'Rejected' },
|
||||
sent: { variant: 'secondary', label: 'Sent' },
|
||||
payment_sent: { variant: 'secondary', label: 'Payment Sent (Pending Confirmation)' },
|
||||
draft: { variant: 'outline', label: 'Draft' },
|
||||
submitted: { variant: 'secondary', label: 'Submitted (Awaiting Approval)' },
|
||||
approved: { variant: 'default', label: 'Approved (Awaiting 10% Payment)' },
|
||||
rejected: { variant: 'destructive', label: 'Rejected' },
|
||||
sent: { variant: 'secondary', label: 'Sent (Awaiting 10% Payment)' },
|
||||
payment_sent: { variant: 'secondary', label: 'Payment Proof Sent (Awaiting Executive Receipt)' },
|
||||
partially_paid: { variant: 'outline', label: 'Partially Paid' },
|
||||
paid: { variant: 'default', label: 'Paid' }, overdue: { variant: 'destructive', label: 'Overdue' },
|
||||
paid: { variant: 'default', label: 'Paid & Verified' },
|
||||
overdue: { variant: 'destructive', label: 'Overdue' },
|
||||
};
|
||||
|
||||
export default function Show({ invoice }: Props) {
|
||||
const { flash, auth } = usePage<PageProps>().props;
|
||||
const [payDialog, setPayDialog] = useState(false);
|
||||
const payForm = useForm({ amount: '' });
|
||||
const [proofModalOpen, setProofModalOpen] = useState(false);
|
||||
const [proofViewerOpen, setProofViewerOpen] = useState(false);
|
||||
const cfg = statusConfig[invoice.status] || statusConfig.draft;
|
||||
const balanceDue = Number(invoice.total_amount) - Number(invoice.paid_amount);
|
||||
|
||||
const roles: string[] = ((auth?.roles || []) as any[]).map(r => (typeof r === 'string' ? r : r.name || '').toLowerCase());
|
||||
const userType = (auth?.user?.user_type || '').toLowerCase();
|
||||
|
||||
const isHigherUp = userType === 'admin' || userType === 'super_admin' || userType === 'project_manager' || roles.some(r => ['super admin', 'admin', 'project manager', 'executive'].includes(r));
|
||||
const isExecutive = isHigherUp;
|
||||
|
||||
const isContractorAdmin = !isHigherUp && (
|
||||
const isContractorAdmin = !isExecutive && (
|
||||
userType === 'contractor'
|
||||
|| auth?.user?.contractor_id !== null
|
||||
|| roles.some(r => r.includes('contractor'))
|
||||
);
|
||||
|
||||
const submitPayment = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
payForm.post(route('finance.payment', invoice.ulid), { onSuccess: () => { payForm.reset(); setPayDialog(false); } });
|
||||
};
|
||||
const hasPenalty = Number(invoice.penalty_amount || 0) > 0;
|
||||
const totalRetentionDue = Number(invoice.retention_amount || 0) + Number(invoice.penalty_amount || 0);
|
||||
|
||||
const isPdfProof = invoice.payment_proof_name?.toLowerCase().endsWith('.pdf');
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('finance.index')}><Button variant="ghost" size="icon-sm"><ArrowLeft className="h-4 w-4" /></Button></Link>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{invoice.invoice_number}</h2>
|
||||
<p className="text-sm text-gray-500">{invoice.project.name} ({invoice.project.code})</p>
|
||||
</div>
|
||||
<Badge variant={cfg.variant}>{cfg.label}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('finance.index')} className="text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{invoice.invoice_number}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">{invoice.project?.name || 'Project'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusConfig[invoice.status]?.variant || 'outline'} className="text-xs">
|
||||
{statusConfig[invoice.status]?.label || invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -82,7 +94,7 @@ export default function Show({ invoice }: Props) {
|
||||
{flash?.success && <div className="rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
|
||||
{flash?.error && <div className="rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
|
||||
|
||||
{/* Primary Action Callout Banner */}
|
||||
{/* 1. Draft Stage */}
|
||||
{invoice.status === 'draft' && (
|
||||
<Card className="border-amber-200 bg-amber-50/60 shadow-xs">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
@@ -105,6 +117,7 @@ export default function Show({ invoice }: Props) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 2. Submitted Stage (Executive Approval) */}
|
||||
{invoice.status === 'submitted' && (
|
||||
<Card className="border-blue-200 bg-blue-50/60 shadow-xs">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
@@ -113,8 +126,8 @@ export default function Show({ invoice }: Props) {
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-900 text-sm">Awaiting Approval</h4>
|
||||
<p className="text-xs text-blue-700">This invoice has been submitted and is currently awaiting approval.</p>
|
||||
<h4 className="font-semibold text-blue-900 text-sm">Awaiting Executive Approval</h4>
|
||||
<p className="text-xs text-blue-700">This invoice has been submitted and is currently awaiting approval from Super Admin, Admin, or Project Manager.</p>
|
||||
</div>
|
||||
</div>
|
||||
{isHigherUp && (
|
||||
@@ -138,60 +151,152 @@ export default function Show({ invoice }: Props) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Executive Payment Release Banner */}
|
||||
{isHigherUp && (invoice.status === 'approved' || invoice.status === 'sent' || invoice.status === 'partially_paid') && (
|
||||
<Card className="border-purple-200 bg-purple-50/60 shadow-xs">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 text-purple-700 rounded-lg shrink-0">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
{/* 3. Approved / Sent Stage -> Contractor 10% Payment Proof Action */}
|
||||
{(invoice.status === 'approved' || invoice.status === 'sent') && (
|
||||
<Card className="border-amber-200 bg-amber-50/70 shadow-xs">
|
||||
<CardContent className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-amber-100 text-amber-800 rounded-lg shrink-0 mt-0.5">
|
||||
<UploadCloud className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-purple-900 text-sm">Release Invoice Payment</h4>
|
||||
<p className="text-xs text-purple-700">Record payment sent to contractor. Contractor must confirm receipt to finalize payment analytics.</p>
|
||||
<h4 className="font-semibold text-amber-950 text-sm">
|
||||
Invoice Approved — {hasPenalty ? 'Payment (with Penalty) & Proof Required' : '10% Payment & Proof Required'}
|
||||
</h4>
|
||||
<p className="text-xs text-amber-800 mt-0.5 leading-relaxed">
|
||||
Contractor must remit the {hasPenalty ? 'payment (including penalty)' : '10% payment'} ({formatCurrency(totalRetentionDue)}) and upload payment proof receipt for Executive confirmation.
|
||||
{hasPenalty && (
|
||||
<span className="block mt-1 font-semibold text-rose-700">
|
||||
• Includes {invoice.penalty_rate}% Late Penalty: +{formatCurrency(invoice.penalty_amount || 0)} (Base Retention: {formatCurrency(invoice.retention_amount || 0)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white font-semibold shadow-sm hover:shadow shrink-0 ml-4"
|
||||
onClick={() => router.post(route('finance.payment', invoice.ulid), { amount: invoice.total_amount })}
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" /> Mark Payment Sent
|
||||
</Button>
|
||||
{isContractorAdmin ? (
|
||||
<Button
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white font-semibold shadow-sm hover:shadow shrink-0"
|
||||
onClick={() => setProofModalOpen(true)}
|
||||
>
|
||||
<UploadCloud className="mr-2 h-4 w-4" /> Send Payment Proof
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-amber-900 bg-amber-200/70 px-3 py-1.5 rounded-lg shrink-0">
|
||||
Awaiting Contractor Proof
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contractor Payment Confirmation Banner */}
|
||||
{invoice.status === 'payment_sent' && isContractorAdmin && (
|
||||
<Card className="border-emerald-200 bg-emerald-50/60 shadow-xs">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-100 text-emerald-700 rounded-lg shrink-0">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
{/* 4. Payment Sent / Proof Submitted Stage -> Executive Confirmation */}
|
||||
{invoice.status === 'payment_sent' && (
|
||||
<Card className="border-indigo-200 bg-indigo-50/70 shadow-xs">
|
||||
<CardContent className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-indigo-100 text-indigo-800 rounded-lg shrink-0 mt-0.5">
|
||||
<DollarSign className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-emerald-900 text-sm">Payment Sent by Executive</h4>
|
||||
<p className="text-xs text-emerald-700">Please verify receipt of {formatCurrency(invoice.total_amount)} and confirm payment receipt to update dashboard totals.</p>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-indigo-950 text-sm">
|
||||
10% Payment Proof Submitted by Contractor
|
||||
</h4>
|
||||
<p className="text-xs text-indigo-800 leading-relaxed">
|
||||
{invoice.payment_proof_name ? `Uploaded Receipt: ${invoice.payment_proof_name}` : 'Contractor has submitted payment evidence.'}
|
||||
{invoice.payment_proof_submitted_at && ` • Submitted on ${new Date(invoice.payment_proof_submitted_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
{invoice.payment_proof_notes && (
|
||||
<p className="text-xs font-medium text-slate-600 bg-white/60 p-1.5 rounded border border-indigo-100">
|
||||
Ref/Notes: {invoice.payment_proof_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold shadow-sm hover:shadow shrink-0 ml-4"
|
||||
onClick={() => router.patch(route('finance.confirm-payment', invoice.ulid))}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" /> Confirm Payment Receipt
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{invoice.payment_proof_path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-white border-indigo-200 text-indigo-900 hover:bg-indigo-50"
|
||||
onClick={() => setProofViewerOpen(true)}
|
||||
>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> View Proof
|
||||
</Button>
|
||||
)}
|
||||
{isHigherUp && (
|
||||
<Button
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold shadow-sm hover:shadow"
|
||||
onClick={() => router.patch(route('finance.receive-payment', invoice.ulid))}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-4 w-4" /> Received Payment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||
<Card><CardContent className="pt-4"><p className="text-xs text-gray-500">Progress</p><p className="text-lg font-bold">{Number(invoice.billed_percentage).toFixed(1)}%</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-4"><p className="text-xs text-gray-500">Subtotal</p><p className="text-lg font-bold">{formatCurrency(invoice.subtotal)}</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-4"><p className="text-xs text-gray-500">Retention ({invoice.retention_rate}%)</p><p className="text-lg font-bold text-gray-500">-{formatCurrency(invoice.retention_amount)}</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-4"><p className="text-xs text-gray-500">Balance Due</p><p className={`text-lg font-bold ${balanceDue > 0 ? 'text-amber-600' : 'text-green-600'}`}>{formatCurrency(balanceDue)}</p></CardContent></Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
{hasPenalty ? `Retention & Penalty (+${invoice.penalty_rate}%)` : `Retention (${invoice.retention_rate}%)`}
|
||||
</p>
|
||||
<p className={`text-lg font-bold font-mono ${isContractorAdmin ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||
{isContractorAdmin ? '-' : '+'}{formatCurrency(totalRetentionDue)}
|
||||
</p>
|
||||
{hasPenalty && (
|
||||
<p className="text-[10px] text-gray-500 font-mono mt-0.5">
|
||||
Base: {formatCurrency(invoice.retention_amount || 0)} | Pen: +{formatCurrency(invoice.penalty_amount || 0)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card><CardContent className="pt-4"><p className="text-xs text-gray-500">Invoice Total</p><p className="text-lg font-bold text-slate-900">{formatCurrency(invoice.total_amount)}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* Payment & Retention Evidence Card */}
|
||||
{invoice.payment_proof_path && (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-bold flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-indigo-600" />
|
||||
10% Payment Evidence & Proof
|
||||
</CardTitle>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Uploaded documentation verifying remittance of 10% payment</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setProofViewerOpen(true)} className="text-xs">
|
||||
<Eye className="mr-1 h-3.5 w-3.5" /> View Document
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2 text-xs space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<div>
|
||||
<span className="text-slate-400 block text-[10px]">Proof File</span>
|
||||
<span className="font-semibold text-slate-800 break-all">{invoice.payment_proof_name || 'Receipt Document'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 block text-[10px]">Submitted By</span>
|
||||
<span className="font-medium text-slate-800">
|
||||
{invoice.payment_proof_submitted_by?.name || 'Contractor Admin'}
|
||||
{invoice.payment_proof_submitted_at && ` on ${new Date(invoice.payment_proof_submitted_at).toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.payment_proof_notes && (
|
||||
<div className="sm:col-span-2 pt-1 border-t border-slate-200/80">
|
||||
<span className="text-slate-400 block text-[10px]">Notes & Reference</span>
|
||||
<p className="font-medium text-slate-700 mt-0.5">{invoice.payment_proof_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Line Items</CardTitle></CardHeader>
|
||||
@@ -199,7 +304,9 @@ export default function Show({ invoice }: Props) {
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Description</TableHead><TableHead className="text-right">Qty</TableHead><TableHead className="text-right">Unit Price</TableHead><TableHead className="text-right">Total</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{invoice.line_items.map(li => (
|
||||
{invoice.line_items.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center text-gray-500">No items.</TableCell></TableRow>
|
||||
) : invoice.line_items.map(li => (
|
||||
<TableRow key={li.id}>
|
||||
<TableCell>{li.description}</TableCell>
|
||||
<TableCell className="text-right">{Number(li.quantity).toFixed(2)}</TableCell>
|
||||
@@ -218,7 +325,7 @@ export default function Show({ invoice }: Props) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Retention Entries</CardTitle>
|
||||
<p className="text-xs text-gray-500 mt-1">Manage project retention releases and payment authorizations</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Project retention releases and ledger balances</p>
|
||||
</div>
|
||||
<Link href={route('retention.index')}>
|
||||
<Button size="sm" variant="default" className="text-xs bg-slate-900 hover:bg-slate-800 text-white shadow-sm">
|
||||
@@ -233,11 +340,13 @@ export default function Show({ invoice }: Props) {
|
||||
{invoice.retention_entries.map(re => (
|
||||
<TableRow key={re.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={re.type === 'debit' ? 'bg-blue-50 text-blue-800 border-blue-200' : 'bg-emerald-50 text-emerald-800 border-emerald-200'}>
|
||||
{re.type === 'debit' ? 'Retention' : 'Released'}
|
||||
<Badge variant="outline" className={re.type === 'debit' ? (isContractorAdmin ? 'bg-rose-50 text-rose-800 border-rose-200' : 'bg-emerald-50 text-emerald-800 border-emerald-200') : 'bg-blue-50 text-blue-800 border-blue-200'}>
|
||||
{re.type === 'debit' ? (isContractorAdmin ? 'Retention Remitted' : 'Retention Held in Escrow') : 'Released'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatCurrency(re.amount)}</TableCell>
|
||||
<TableCell className={`text-right font-semibold font-mono ${isContractorAdmin ? (re.type === 'debit' ? 'text-rose-600' : 'text-emerald-600') : (re.type === 'debit' ? 'text-emerald-600' : 'text-amber-600')}`}>
|
||||
{isContractorAdmin ? (re.type === 'debit' ? '-' : '+') : (re.type === 'debit' ? '+' : '-')}{formatCurrency(re.amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{re.description}</TableCell>
|
||||
<TableCell className="text-gray-500">{new Date(re.created_at).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
@@ -253,15 +362,68 @@ export default function Show({ invoice }: Props) {
|
||||
<CardHeader><CardTitle>Timeline</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex gap-3"><span className="text-gray-500 w-28">Created</span><span>{new Date(invoice.invoice_date).toLocaleDateString()}</span></div>
|
||||
{invoice.submitted_at && <div className="flex gap-3"><span className="text-gray-500 w-28">Submitted</span><span>{new Date(invoice.submitted_at).toLocaleDateString()}</span></div>}
|
||||
{invoice.approved_at && <div className="flex gap-3"><span className="text-gray-500 w-28">Approved</span><span>{new Date(invoice.approved_at).toLocaleDateString()}</span></div>}
|
||||
{invoice.sent_at && <div className="flex gap-3"><span className="text-gray-500 w-28">Sent</span><span>{new Date(invoice.sent_at).toLocaleDateString()}</span></div>}
|
||||
{invoice.paid_at && <div className="flex gap-3"><span className="text-gray-500 w-28">Paid</span><span>{new Date(invoice.paid_at).toLocaleDateString()}</span></div>}
|
||||
<div className="flex gap-3"><span className="text-gray-500 w-36">Created</span><span>{new Date(invoice.invoice_date).toLocaleDateString()}</span></div>
|
||||
{invoice.submitted_at && <div className="flex gap-3"><span className="text-gray-500 w-36">Submitted</span><span>{new Date(invoice.submitted_at).toLocaleDateString()}</span></div>}
|
||||
{invoice.approved_at && <div className="flex gap-3"><span className="text-gray-500 w-36">Approved</span><span>{new Date(invoice.approved_at).toLocaleDateString()}</span></div>}
|
||||
{invoice.payment_proof_submitted_at && (
|
||||
<div className="flex gap-3">
|
||||
<span className="text-gray-500 w-36">Proof Sent</span>
|
||||
<span>{new Date(invoice.payment_proof_submitted_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.paid_at && <div className="flex gap-3"><span className="text-gray-500 w-36">Paid & Verified</span><span>{new Date(invoice.paid_at).toLocaleDateString()}</span></div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div></div>
|
||||
|
||||
{/* Contractor 10% Payment Proof Upload Modal */}
|
||||
<RetentionPaymentProofModal
|
||||
isOpen={proofModalOpen}
|
||||
onClose={() => setProofModalOpen(false)}
|
||||
invoice={{
|
||||
ulid: invoice.ulid,
|
||||
invoice_number: invoice.invoice_number,
|
||||
total_amount: invoice.total_amount,
|
||||
retention_amount: invoice.retention_amount,
|
||||
retention_rate: invoice.retention_rate,
|
||||
project: invoice.project,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Proof Document Viewer Dialog */}
|
||||
<Dialog open={proofViewerOpen} onOpenChange={setProofViewerOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold flex items-center justify-between">
|
||||
<span>Payment Proof: {invoice.payment_proof_name || 'Receipt'}</span>
|
||||
<a
|
||||
href={route('finance.payment-proof.view', invoice.ulid)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-indigo-600 hover:underline font-normal mr-6"
|
||||
>
|
||||
Open in new tab ↗
|
||||
</a>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-2 min-h-[450px] max-h-[75vh] overflow-auto flex items-center justify-center bg-slate-100 rounded-xl p-2">
|
||||
{isPdfProof ? (
|
||||
<iframe
|
||||
src={route('finance.payment-proof.view', invoice.ulid)}
|
||||
className="w-full h-[600px] rounded-lg border border-slate-200"
|
||||
title="Payment Proof PDF"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={route('finance.payment-proof.view', invoice.ulid)}
|
||||
alt="Payment Proof"
|
||||
className="max-w-full max-h-[600px] object-contain rounded-lg shadow-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ 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 { CheckCircle2, CreditCard, Eye, Upload, Wallet } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, Clock, Eye, Upload, Wallet } from 'lucide-react';
|
||||
import { FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/Components/ui/dialog';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import RetentionPenaltyModal, { OverdueInvoiceItem } from '../../Components/RetentionPenaltyModal';
|
||||
|
||||
interface RetEntry {
|
||||
id: number; ulid: string; type: string; status?: string; amount: string; description?: string; media_path?: string | null; media_original_name?: string | null; created_at: string;
|
||||
@@ -23,21 +24,21 @@ interface Props extends PageProps {
|
||||
entries: PaginatedData<RetEntry>;
|
||||
projects: { id: number; ulid: string; name: string; code: string; status?: string }[];
|
||||
projectTotals: Record<string, { held: number; released: number; balance: number; pending_release_ulid?: string; pending_release_status?: string; pending_release_amount?: number; pending_release_media_path?: string | null; pending_release_media_name?: string | null }>;
|
||||
overdueInvoices?: OverdueInvoiceItem[];
|
||||
filters: { project_id?: string };
|
||||
}
|
||||
|
||||
const formatCurrency = (v: number | string) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
||||
|
||||
export default function Index({ entries, projects, projectTotals, filters }: Props) {
|
||||
export default function Index({ entries, projects, projectTotals, overdueInvoices = [], filters }: Props) {
|
||||
const { flash, auth } = usePage<PageProps>().props;
|
||||
const [projectFilter, setProjectFilter] = useState(filters.project_id || 'all');
|
||||
const [submitProject, setSubmitProject] = useState<{ ulid: string; name: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mediaViewerEntry, setMediaViewerEntry] = useState<RetEntry | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<RetEntry | null>(null);
|
||||
const [markPaidEntry, setMarkPaidEntry] = useState<RetEntry | null>(null);
|
||||
const [penaltyInvoice, setPenaltyInvoice] = useState<OverdueInvoiceItem | null>(null);
|
||||
const mediaRef = useRef<HTMLInputElement>(null);
|
||||
const markPaidMediaRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const roles: string[] = ((auth?.roles || []) as any[]).map(r => (typeof r === 'string' ? r : r.name || '').toLowerCase());
|
||||
const userType = (auth?.user?.user_type || '').toLowerCase();
|
||||
@@ -80,21 +81,6 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkPaidWithProof = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const file = markPaidMediaRef.current?.files?.[0];
|
||||
if (!file || !markPaidEntry) return;
|
||||
setSubmitting(true);
|
||||
const formData = new FormData();
|
||||
formData.append('media', file);
|
||||
formData.append('_method', 'PATCH');
|
||||
router.post(route('retention.paid', markPaidEntry.ulid), formData, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => setMarkPaidEntry(null),
|
||||
onFinish: () => setSubmitting(false),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
@@ -119,10 +105,10 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-sm font-medium">{proj?.name || `Project #${projId}`}</p>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Held</span><span className="text-red-600">{formatCurrency(t.held)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Released</span><span className="text-green-600">{formatCurrency(t.released)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Held</span><span className="text-red-600 font-mono">{formatCurrency(t.held)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Released / Settled</span><span className="text-green-600 font-mono">{formatCurrency(t.released)}</span></div>
|
||||
<hr />
|
||||
<div className="flex justify-between font-bold"><span>Balance</span><span>{formatCurrency(t.balance)}</span></div>
|
||||
<div className="flex justify-between font-bold"><span>Balance</span><span className={`font-mono ${t.balance > 0 ? 'text-amber-600' : 'text-slate-900'}`}>{formatCurrency(t.balance)}</span></div>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
{t.balance > 0 && !t.pending_release_ulid && proj && ['completed', 'closed'].includes(proj.status || '') && (
|
||||
<Button size="sm" variant="outline" onClick={() => setSubmitProject({ ulid: proj.ulid, name: proj.name })}>
|
||||
@@ -139,11 +125,6 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
<Eye className="mr-1 h-3.5 w-3.5" /> View Proof
|
||||
</Button>
|
||||
)}
|
||||
{isApprover && t.pending_release_status !== 'payment_sent' && (
|
||||
<Button size="sm" onClick={() => setMarkPaidEntry({ id: 0, ulid: t.pending_release_ulid!, type: 'credit', amount: String(t.pending_release_amount || 0), media_path: t.pending_release_media_path, media_original_name: t.pending_release_media_name, created_at: '' })}>
|
||||
<CreditCard className="mr-1 h-3.5 w-3.5" /> Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
{isContractorAdmin && t.pending_release_status && t.pending_release_status !== 'paid' && (
|
||||
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold shadow-xs" onClick={() => router.patch(route('retention.confirm', t.pending_release_ulid))}>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" /> Confirm Receipt
|
||||
@@ -160,6 +141,104 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdue Invoices (> 2 Months Unpaid) Table */}
|
||||
{overdueInvoices && overdueInvoices.length > 0 && (
|
||||
<Card className="border-red-200 bg-gradient-to-b from-red-50/25 to-white shadow-xs overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-red-100 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-red-50/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 text-red-700 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-gray-900 tracking-tight">
|
||||
Overdue Invoices & Retention Penalties (Unpaid > 2 Months)
|
||||
</h3>
|
||||
<Badge className="bg-red-600 text-white font-mono text-[10px] px-2 py-0.5">
|
||||
{overdueInvoices.length} {overdueInvoices.length === 1 ? 'Invoice' : 'Invoices'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Approved progress invoices with unpaid 10% retention older than 60 days. Click any row to configure and assess percentage-based penalties.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/80 hover:bg-slate-50/80 text-xs">
|
||||
<TableHead className="font-bold">Project</TableHead>
|
||||
<TableHead className="font-bold">Invoice #</TableHead>
|
||||
<TableHead className="font-bold">Invoice Date</TableHead>
|
||||
<TableHead className="font-bold">Overdue Duration</TableHead>
|
||||
<TableHead className="font-bold text-right">Subtotal</TableHead>
|
||||
<TableHead className="font-bold text-right">10% Retention</TableHead>
|
||||
<TableHead className="font-bold">Penalty Status</TableHead>
|
||||
<TableHead className="text-right font-bold">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overdueInvoices.map((inv) => {
|
||||
const isPenalized = inv.penalty_rate !== null && Number(inv.penalty_amount) > 0;
|
||||
return (
|
||||
<TableRow
|
||||
key={inv.id}
|
||||
className="cursor-pointer hover:bg-red-50/40 transition-colors group"
|
||||
onClick={() => setPenaltyInvoice(inv)}
|
||||
>
|
||||
<TableCell className="font-semibold text-gray-900">
|
||||
{inv.project?.name || 'Project unavailable'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-gray-600">
|
||||
{inv.invoice_number}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-gray-500">
|
||||
{inv.invoice_date}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 font-mono text-xs">
|
||||
<Clock className="w-3 h-3 mr-1 text-red-500" />
|
||||
{inv.days_overdue} days overdue
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs text-gray-700 tabular-nums">
|
||||
{formatCurrency(inv.subtotal)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs font-bold text-rose-600 tabular-nums">
|
||||
{formatCurrency(inv.retention_amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isPenalized ? (
|
||||
<Badge className="bg-amber-100 hover:bg-amber-200 text-amber-800 border border-amber-300 text-[11px] font-semibold">
|
||||
Penalized: {inv.penalty_rate}% ({formatCurrency(inv.penalty_amount)})
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-slate-100 text-slate-600 border-slate-200 text-[11px]">
|
||||
No Penalty Assessed
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPenaltyInvoice(inv)}
|
||||
className="text-xs h-7 px-2.5 border-red-200 text-red-700 hover:bg-red-50 hover:text-red-800 font-semibold shadow-2xs"
|
||||
>
|
||||
{isPenalized ? 'Edit Penalty' : 'Assess Penalty'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<DataTableToolbar
|
||||
filters={
|
||||
@@ -190,11 +269,13 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
<TableCell className="font-medium">{e.project?.name || 'Project unavailable'}</TableCell>
|
||||
<TableCell className="text-gray-500">{e.invoice?.invoice_number || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={e.type === 'debit' ? 'bg-blue-50 text-blue-800 border-blue-200' : 'bg-emerald-50 text-emerald-800 border-emerald-200'}>
|
||||
{e.type === 'debit' ? 'Retention' : 'Released'}
|
||||
<Badge variant="outline" className={e.type === 'debit' ? (isContractorAdmin ? 'bg-rose-50 text-rose-800 border-rose-200' : 'bg-emerald-50 text-emerald-800 border-emerald-200') : 'bg-blue-50 text-blue-800 border-blue-200'}>
|
||||
{e.type === 'debit' ? (isContractorAdmin ? 'Retention Remitted' : 'Retention Held in Escrow') : 'Settled & Released'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">{formatCurrency(e.amount)}</TableCell>
|
||||
<TableCell className={`text-right font-medium tabular-nums font-mono ${isContractorAdmin ? (e.type === 'debit' ? 'text-rose-600' : 'text-emerald-600') : (e.type === 'debit' ? 'text-emerald-600' : 'text-amber-600')}`}>
|
||||
{isContractorAdmin ? (e.type === 'debit' ? '-' : '+') : (e.type === 'debit' ? '+' : '-')}{formatCurrency(e.amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{e.description || '-'}</TableCell>
|
||||
<TableCell className="text-gray-500">{new Date(e.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell onClick={(evt) => evt.stopPropagation()}>
|
||||
@@ -274,19 +355,6 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isApprover && (selectedEntry.type === 'debit' || (selectedEntry.type === 'credit' && ['submitted', 'posted'].includes(selectedEntry.status || ''))) && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => {
|
||||
const e = selectedEntry;
|
||||
setSelectedEntry(null);
|
||||
setMarkPaidEntry(e);
|
||||
}}
|
||||
>
|
||||
<CreditCard className="mr-1.5 h-3.5 w-3.5" /> Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
{isContractorAdmin && selectedEntry.type === 'credit' && selectedEntry.status !== 'paid' && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -331,33 +399,6 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Mark as Paid PDF Proof Modal */}
|
||||
<Dialog open={!!markPaidEntry} onOpenChange={(open) => { if (!open) setMarkPaidEntry(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mark Retention as Paid</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleMarkPaidWithProof} className="space-y-4">
|
||||
<div className="rounded-md bg-emerald-50 p-3 text-sm text-emerald-800 border border-emerald-200">
|
||||
Upload the Bank Transfer Receipt, Official Receipt, or Voucher PDF proof to complete payment.
|
||||
</div>
|
||||
<div>
|
||||
<Label>Proof of Payment Document (PDF) *</Label>
|
||||
<Input ref={markPaidMediaRef} type="file" accept=".pdf,application/pdf" required />
|
||||
<p className="mt-1 text-xs text-gray-500">PDF documents only — max 10MB</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setMarkPaidEntry(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-emerald-600 hover:bg-emerald-700 text-white" disabled={submitting}>
|
||||
{submitting ? 'Processing Payment...' : 'Confirm & Mark as Paid'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!mediaViewerEntry} onOpenChange={(open) => { if (!open) setMediaViewerEntry(null); }}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader><DialogTitle>Uploaded Retention Payment Proof</DialogTitle></DialogHeader>
|
||||
@@ -380,6 +421,14 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Overdue Retention Penalty Assessment Modal */}
|
||||
<RetentionPenaltyModal
|
||||
invoice={penaltyInvoice}
|
||||
isOpen={!!penaltyInvoice}
|
||||
onClose={() => setPenaltyInvoice(null)}
|
||||
isExecutive={isExecutive}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ Route::middleware(['web', 'auth', 'permission:finance.access'])->group(function
|
||||
Route::patch('finance/{invoice}/approve', [FinanceController::class, 'approve'])->name('finance.approve');
|
||||
Route::patch('finance/{invoice}/reject', [FinanceController::class, 'reject'])->name('finance.reject');
|
||||
Route::patch('finance/{invoice}/send', [FinanceController::class, 'send'])->name('finance.send');
|
||||
Route::post('finance/{invoice}/send-payment-proof', [FinanceController::class, 'sendPaymentProof'])->name('finance.send-payment-proof');
|
||||
Route::patch('finance/{invoice}/receive-payment', [FinanceController::class, 'receivePayment'])->name('finance.receive-payment');
|
||||
Route::get('finance/{invoice}/payment-proof/view', [FinanceController::class, 'viewPaymentProof'])->name('finance.payment-proof.view');
|
||||
Route::post('finance/{invoice}/apply-penalty', [FinanceController::class, 'applyPenalty'])->name('finance.apply-penalty');
|
||||
Route::post('finance/{invoice}/payment', [FinanceController::class, 'recordPayment'])->name('finance.payment');
|
||||
Route::patch('finance/{invoice}/confirm-payment', [FinanceController::class, 'confirmPayment'])->name('finance.confirm-payment');
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\FinancialManagement\Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\FinancialManagement\Enums\InvoiceStatus;
|
||||
use Modules\FinancialManagement\Models\FinancialInvoice;
|
||||
use Modules\FinancialManagement\Models\RetentionEntry;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RevisedRetentionFlowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $superAdmin;
|
||||
protected User $admin;
|
||||
protected User $projectManager;
|
||||
protected User $contractorAdmin;
|
||||
protected Project $project;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Role::firstOrCreate(['name' => 'Super Admin']);
|
||||
Role::firstOrCreate(['name' => 'Admin']);
|
||||
Role::firstOrCreate(['name' => 'Project Manager']);
|
||||
Role::firstOrCreate(['name' => 'Main Contractor Admin']);
|
||||
|
||||
$financePermission = Permission::firstOrCreate(['name' => 'finance.access']);
|
||||
|
||||
$this->superAdmin = User::factory()->create(['user_type' => 'admin']);
|
||||
$this->superAdmin->assignRole('Super Admin');
|
||||
$this->superAdmin->givePermissionTo($financePermission);
|
||||
|
||||
$this->admin = User::factory()->create(['user_type' => 'admin']);
|
||||
$this->admin->assignRole('Admin');
|
||||
$this->admin->givePermissionTo($financePermission);
|
||||
|
||||
$this->projectManager = User::factory()->create(['user_type' => 'employee']);
|
||||
$this->projectManager->assignRole('Project Manager');
|
||||
$this->projectManager->givePermissionTo($financePermission);
|
||||
|
||||
$this->contractorAdmin = User::factory()->create(['user_type' => 'contractor']);
|
||||
$this->contractorAdmin->assignRole('Main Contractor Admin');
|
||||
$this->contractorAdmin->givePermissionTo($financePermission);
|
||||
|
||||
$this->project = Project::create([
|
||||
'name' => 'Metro Office Tower',
|
||||
'code' => 'PRJ-METRO-01',
|
||||
'status' => 'in_progress',
|
||||
'budget' => 5000000,
|
||||
'contract_value' => 5000000,
|
||||
'total_capitalization' => 5000000,
|
||||
'completion_percentage' => 0,
|
||||
'current_wizard_step' => 8,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_contractor_admin_can_generate_progress_invoice(): void
|
||||
{
|
||||
$response = $this->actingAs($this->contractorAdmin)->post(route('finance.store'), [
|
||||
'project_id' => $this->project->ulid,
|
||||
'current_percentage' => 20.00,
|
||||
'retention_rate' => 10.00,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('finance.index'));
|
||||
$this->assertDatabaseHas('financial_invoices', [
|
||||
'project_id' => $this->project->id,
|
||||
'billed_percentage' => 20.00,
|
||||
'retention_rate' => 10.00,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_superadmin_admin_and_pm_can_approve_invoice_and_hold_retention(): void
|
||||
{
|
||||
$invoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-TEST-001',
|
||||
'status' => InvoiceStatus::Submitted,
|
||||
'subtotal' => 1000000.00,
|
||||
'retention_amount' => 100000.00,
|
||||
'total_amount' => 900000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 20.00,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
// Project Manager approves invoice
|
||||
$response = $this->actingAs($this->projectManager)
|
||||
->patch(route('finance.approve', $invoice->ulid));
|
||||
|
||||
$response->assertSessionHas('success');
|
||||
$invoice->refresh();
|
||||
$this->assertEquals(InvoiceStatus::Approved, $invoice->status);
|
||||
|
||||
// Retention debit entry created in ledger
|
||||
$this->assertDatabaseHas('retention_ledger', [
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'type' => 'debit',
|
||||
'amount' => 100000.00,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_contractor_admin_can_send_10_percent_payment_proof(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$invoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-TEST-002',
|
||||
'status' => InvoiceStatus::Approved,
|
||||
'subtotal' => 1000000.00,
|
||||
'retention_amount' => 100000.00,
|
||||
'total_amount' => 900000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 20.00,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->create('payment_receipt_10percent.pdf', 200, 'application/pdf');
|
||||
|
||||
$response = $this->actingAs($this->contractorAdmin)->post(route('finance.send-payment-proof', $invoice->ulid), [
|
||||
'media' => $file,
|
||||
'notes' => '10% Retention Remittance via BDO Bank Transfer Ref #992144',
|
||||
]);
|
||||
|
||||
$response->assertSessionHas('success');
|
||||
$invoice->refresh();
|
||||
|
||||
$this->assertEquals(InvoiceStatus::PaymentSent, $invoice->status);
|
||||
$this->assertNotNull($invoice->payment_proof_path);
|
||||
$this->assertEquals('payment_receipt_10percent.pdf', $invoice->payment_proof_name);
|
||||
$this->assertEquals('10% Retention Remittance via BDO Bank Transfer Ref #992144', $invoice->payment_proof_notes);
|
||||
$this->assertEquals($this->contractorAdmin->id, $invoice->payment_proof_submitted_by);
|
||||
$this->assertNotNull($invoice->payment_proof_submitted_at);
|
||||
|
||||
Storage::disk('public')->assertExists($invoice->payment_proof_path);
|
||||
}
|
||||
|
||||
public function test_executive_can_view_proof_and_confirm_payment_received(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$file = UploadedFile::fake()->create('proof.pdf', 100, 'application/pdf');
|
||||
$path = $file->store('invoice_payment_proofs/1', 'public');
|
||||
|
||||
$invoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-TEST-003',
|
||||
'status' => InvoiceStatus::PaymentSent,
|
||||
'subtotal' => 1000000.00,
|
||||
'retention_amount' => 100000.00,
|
||||
'total_amount' => 900000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 20.00,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
'payment_proof_path' => $path,
|
||||
'payment_proof_name' => 'proof.pdf',
|
||||
'payment_proof_submitted_at' => now(),
|
||||
'payment_proof_submitted_by' => $this->contractorAdmin->id,
|
||||
]);
|
||||
|
||||
// Executive can view payment proof
|
||||
$viewResponse = $this->actingAs($this->superAdmin)
|
||||
->get(route('finance.payment-proof.view', $invoice->ulid));
|
||||
$viewResponse->assertOk();
|
||||
|
||||
// Project Manager confirms received payment
|
||||
$confirmResponse = $this->actingAs($this->projectManager)
|
||||
->patch(route('finance.receive-payment', $invoice->ulid), [
|
||||
'notes' => 'Verified with Treasury. Payment confirmed.',
|
||||
]);
|
||||
|
||||
$confirmResponse->assertSessionHas('success');
|
||||
$invoice->refresh();
|
||||
|
||||
$this->assertEquals(InvoiceStatus::Paid, $invoice->status);
|
||||
$this->assertEquals(900000.00, (float) $invoice->paid_amount);
|
||||
$this->assertNotNull($invoice->paid_at);
|
||||
$this->assertEquals(20.00, (float) $this->project->refresh()->completion_percentage);
|
||||
}
|
||||
|
||||
public function test_contractor_cannot_confirm_received_payment_on_behalf_of_executive(): void
|
||||
{
|
||||
$invoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-TEST-004',
|
||||
'status' => InvoiceStatus::PaymentSent,
|
||||
'subtotal' => 1000000.00,
|
||||
'retention_amount' => 100000.00,
|
||||
'total_amount' => 900000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 20.00,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->contractorAdmin)
|
||||
->patch(route('finance.receive-payment', $invoice->ulid));
|
||||
|
||||
$response->assertSessionHas('error');
|
||||
$invoice->refresh();
|
||||
$this->assertNotEquals(InvoiceStatus::Paid, $invoice->status);
|
||||
}
|
||||
|
||||
public function test_overdue_invoices_past_2_months_appear_in_retention_index(): void
|
||||
{
|
||||
// Invoice created 75 days ago (older than 2 months)
|
||||
$overdueInvoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-OVERDUE-01',
|
||||
'status' => InvoiceStatus::Approved,
|
||||
'subtotal' => 2000000.00,
|
||||
'retention_amount' => 200000.00,
|
||||
'total_amount' => 1800000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 40.00,
|
||||
'invoice_date' => now()->subDays(75)->toDateString(),
|
||||
'approved_at' => now()->subDays(75),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->superAdmin)
|
||||
->get(route('retention.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('FinancialManagement::Retention/Index', false)
|
||||
->has('overdueInvoices', 1)
|
||||
->where('overdueInvoices.0.invoice_number', 'INV-OVERDUE-01')
|
||||
->where('overdueInvoices.0.retention_amount', 200000)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_executive_can_apply_percentage_penalty_to_overdue_invoice(): void
|
||||
{
|
||||
$overdueInvoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-OVERDUE-02',
|
||||
'status' => InvoiceStatus::Approved,
|
||||
'subtotal' => 1000000.00,
|
||||
'retention_amount' => 100000.00,
|
||||
'total_amount' => 900000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 20.00,
|
||||
'invoice_date' => now()->subDays(65)->toDateString(),
|
||||
]);
|
||||
|
||||
// Super Admin applies a 5% penalty
|
||||
$response = $this->actingAs($this->superAdmin)
|
||||
->post(route('finance.apply-penalty', $overdueInvoice->ulid), [
|
||||
'penalty_rate' => 5.00,
|
||||
'reason' => 'Overdue 65 days without remittance confirmation.',
|
||||
]);
|
||||
|
||||
$response->assertSessionHas('success');
|
||||
$overdueInvoice->refresh();
|
||||
|
||||
// 5% of 100,000 retention = 5,000.00 penalty
|
||||
$this->assertEquals(5.00, (float) $overdueInvoice->penalty_rate);
|
||||
$this->assertEquals(5000.00, (float) $overdueInvoice->penalty_amount);
|
||||
$this->assertEquals('Overdue 65 days without remittance confirmation.', $overdueInvoice->penalty_reason);
|
||||
$this->assertEquals($this->superAdmin->id, $overdueInvoice->penalty_applied_by);
|
||||
$this->assertNotNull($overdueInvoice->penalty_applied_at);
|
||||
|
||||
// Penalty adjustment recorded in retention ledger
|
||||
$this->assertDatabaseHas('retention_ledger', [
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_id' => $overdueInvoice->id,
|
||||
'type' => 'debit',
|
||||
'amount' => 5000.00,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_contractor_cannot_apply_penalty(): void
|
||||
{
|
||||
$overdueInvoice = FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
'invoice_number' => 'INV-OVERDUE-03',
|
||||
'status' => InvoiceStatus::Approved,
|
||||
'subtotal' => 1000000.00,
|
||||
'retention_amount' => 100000.00,
|
||||
'total_amount' => 900000.00,
|
||||
'retention_rate' => 10.00,
|
||||
'billed_percentage' => 20.00,
|
||||
'invoice_date' => now()->subDays(70)->toDateString(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->contractorAdmin)
|
||||
->post(route('finance.apply-penalty', $overdueInvoice->ulid), [
|
||||
'penalty_rate' => 5.00,
|
||||
'reason' => 'Attempted unauthorized penalty.',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertNull($overdueInvoice->refresh()->penalty_rate);
|
||||
}
|
||||
}
|
||||
@@ -350,8 +350,7 @@ export default function Index({ labors = [], skills = [] }: Props) {
|
||||
category: 'Civil & Construction',
|
||||
description: '',
|
||||
items: [
|
||||
{ labor_name: 'Lead Craftsman', category: 'skilled', rate_per_sqm: 120 },
|
||||
{ labor_name: 'General Assistant', category: 'unskilled', rate_per_sqm: 60 }
|
||||
{ labor_name: '', category: 'skilled', rate_per_sqm: 0 }
|
||||
]
|
||||
});
|
||||
setBundleModalOpen(true);
|
||||
@@ -406,7 +405,11 @@ export default function Index({ labors = [], skills = [] }: Props) {
|
||||
const addBundleItemRow = () => {
|
||||
setBundleForm({
|
||||
...bundleForm,
|
||||
items: [...bundleForm.items, { labor_name: '', category: 'skilled', rate_per_sqm: 80 }]
|
||||
items: [...bundleForm.items, {
|
||||
labor_name: '',
|
||||
category: 'skilled',
|
||||
rate_per_sqm: 0
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
@@ -423,6 +426,19 @@ export default function Index({ labors = [], skills = [] }: Props) {
|
||||
setBundleForm({ ...bundleForm, items: updated });
|
||||
};
|
||||
|
||||
const handleLaborSelect = (idx: number, laborName: string) => {
|
||||
const matchedLabor = labors.find(l => l.name === laborName);
|
||||
const updated = bundleForm.items.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
return {
|
||||
...item,
|
||||
labor_name: laborName,
|
||||
category: matchedLabor ? matchedLabor.category : item.category,
|
||||
};
|
||||
});
|
||||
setBundleForm({ ...bundleForm, items: updated });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
@@ -864,32 +880,41 @@ export default function Index({ labors = [], skills = [] }: Props) {
|
||||
{bundleForm.items.map((item, idx) => (
|
||||
<div key={idx} className="p-3 bg-slate-50 border border-slate-200 rounded-lg space-y-2 text-xs">
|
||||
<div className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-4">
|
||||
<div className="col-span-7">
|
||||
<Label className="text-[10px] text-slate-500">Trade / Position</Label>
|
||||
<Input
|
||||
value={item.labor_name}
|
||||
onChange={e => updateBundleItemRow(idx, 'labor_name', e.target.value)}
|
||||
placeholder="e.g. Steel Fixer"
|
||||
required
|
||||
className="h-8 text-xs bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<Label className="text-[10px] text-slate-500">Category</Label>
|
||||
<Select
|
||||
value={item.category}
|
||||
onValueChange={v => updateBundleItemRow(idx, 'category', v)}
|
||||
value={item.labor_name}
|
||||
onValueChange={v => handleLaborSelect(idx, v || '')}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-white">
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="Select single laborer..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skilled">Skilled</SelectItem>
|
||||
<SelectItem value="unskilled">Unskilled</SelectItem>
|
||||
{labors.length === 0 ? (
|
||||
<SelectItem value="none" disabled>
|
||||
No single laborers found
|
||||
</SelectItem>
|
||||
) : (
|
||||
labors.map((l) => (
|
||||
<SelectItem key={l.id} value={l.name}>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<span className="font-medium">{l.name}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
({l.category === 'skilled' ? 'Skilled' : 'Unskilled'} · ₱{Number(l.hourly_rate).toFixed(0)}/hr)
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
{item.labor_name && !labors.some(l => l.name === item.labor_name) && (
|
||||
<SelectItem value={item.labor_name}>
|
||||
{item.labor_name}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-4">
|
||||
<Label className="text-[10px] text-slate-500">Est. Rate per m² (PHP)</Label>
|
||||
<Input
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Search, Package, Plus, Layers, SearchIcon } from 'lucide-react';
|
||||
import { Search, Package, Plus, Layers, SearchIcon, Check, Sparkles } from 'lucide-react';
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
export interface MaterialOption {
|
||||
@@ -43,9 +43,14 @@ export function MaterialCatalogModal({
|
||||
onAddKit
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [kitQuantity, setKitQuantity] = useState<Record<string, string>>({});
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<MaterialOption[]>([]);
|
||||
|
||||
const formatCurrency = (v: string | number) => {
|
||||
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) setSelectedMaterials([]);
|
||||
onOpenChange(isOpen);
|
||||
@@ -59,25 +64,37 @@ export function MaterialCatalogModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Extract unique categories for pill filters
|
||||
const uniqueCategories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
materials.forEach(m => {
|
||||
if (m.category) cats.add(m.category);
|
||||
});
|
||||
return Array.from(cats).sort();
|
||||
}, [materials]);
|
||||
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (!search) return materials;
|
||||
const lowerSearch = search.toLowerCase();
|
||||
return materials.filter(m =>
|
||||
m.name.toLowerCase().includes(lowerSearch) ||
|
||||
(m.sku && m.sku.toLowerCase().includes(lowerSearch)) ||
|
||||
(m.category && m.category.toLowerCase().includes(lowerSearch))
|
||||
);
|
||||
}, [materials, search]);
|
||||
return materials.filter(m => {
|
||||
const matchesSearch = !search || (
|
||||
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(m.sku && m.sku.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(m.category && m.category.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' || m.category === categoryFilter;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [materials, search, categoryFilter]);
|
||||
|
||||
// Group materials by category for display
|
||||
const groupedMaterials = useMemo(() => {
|
||||
const groups: Record<string, MaterialOption[]> = {};
|
||||
filteredMaterials.forEach(m => {
|
||||
const cat = m.category || 'Uncategorized';
|
||||
const cat = m.category || 'General Materials';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(m);
|
||||
});
|
||||
// Sort keys
|
||||
return Object.keys(groups).sort().reduce((acc, key) => {
|
||||
acc[key] = groups[key];
|
||||
return acc;
|
||||
@@ -86,156 +103,247 @@ export function MaterialCatalogModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[85vw] max-w-[95vw] max-h-[92vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<Package className="h-5 w-5 text-indigo-600" />
|
||||
Material Catalog
|
||||
<DialogContent className="sm:max-w-5xl max-w-[95vw] max-h-[88vh] h-[720px] flex flex-col p-0 overflow-hidden bg-slate-50/50 border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4 bg-white border-b border-slate-100 flex flex-col gap-1">
|
||||
<DialogTitle className="text-xl font-bold text-slate-800 flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-indigo-50 border border-indigo-100 text-indigo-600">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<span>Material Catalog & Assembly Kits</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Browse individual materials or quickly add entire predefined assemblies.
|
||||
<DialogDescription className="text-xs text-slate-500">
|
||||
Browse single material items or quickly add predefined BOM assembly kits to your requisition.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="items" className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 py-2 border-b bg-gray-50/50 flex justify-between items-center">
|
||||
<TabsList>
|
||||
<TabsTrigger value="items" className="flex items-center gap-2">
|
||||
<SearchIcon className="h-4 w-4" /> Single Items
|
||||
{/* Navigation and Search Bar */}
|
||||
<div className="px-6 py-2.5 border-b border-slate-100 bg-slate-50/70 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<TabsList className="bg-slate-200/60 p-1">
|
||||
<TabsTrigger value="items" className="text-xs font-semibold data-[state=active]:bg-white data-[state=active]:text-slate-800 flex items-center gap-1.5">
|
||||
<SearchIcon className="h-3.5 w-3.5" /> Single Items
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="kits" className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" /> Assemblies & Kits
|
||||
<TabsTrigger value="kits" className="text-xs font-semibold data-[state=active]:bg-white data-[state=active]:text-slate-800 flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" /> Assemblies & Kits
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search Name, SKU, Category..."
|
||||
className="h-9 pl-9 bg-white"
|
||||
className="h-9 pl-9 bg-white text-xs border-slate-200 focus-visible:ring-indigo-500 focus-visible:border-indigo-500"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<TabsContent value="items" className="m-0 p-6">
|
||||
{Object.keys(groupedMaterials).length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No materials found matching "{search}".
|
||||
{/* TAB 1: SINGLE ITEMS */}
|
||||
<TabsContent value="items" className="flex-1 overflow-y-auto p-6 min-h-0 m-0 space-y-4">
|
||||
{/* Category Filter Pills */}
|
||||
{uniqueCategories.length > 0 && (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<span className="text-xs font-semibold text-slate-500 shrink-0">Category:</span>
|
||||
<div className="flex bg-slate-100 p-0.5 rounded-lg border border-slate-200/40 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter('all')}
|
||||
className={`px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
||||
categoryFilter === 'all'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
All ({materials.length})
|
||||
</button>
|
||||
{uniqueCategories.map(cat => {
|
||||
const count = materials.filter(m => m.category === cat).length;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={`px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
||||
categoryFilter === cat
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{cat} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedMaterials).map(([category, items]) => (
|
||||
<div key={category} className="space-y-3">
|
||||
<h3 className="font-semibold text-lg text-gray-900 border-b pb-1">
|
||||
{category} <span className="text-gray-400 text-sm ml-2 font-normal">({items.length})</span>
|
||||
</h3>
|
||||
<div className="border rounded-lg overflow-hidden relative shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 text-gray-600 font-medium border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-10"></th>
|
||||
<th className="px-4 py-3 w-32 whitespace-nowrap">SKU</th>
|
||||
<th className="px-4 py-3 min-w-[200px] w-full">Material Name</th>
|
||||
<th className="px-4 py-3 w-28 whitespace-nowrap">Unit</th>
|
||||
<th className="px-4 py-3 w-32 whitespace-nowrap text-right">Unit Cost</th>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(groupedMaterials).length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-xs">
|
||||
No materials found matching "{search}".
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedMaterials).map(([category, items]) => (
|
||||
<div key={category} className="space-y-2">
|
||||
<h3 className="font-bold text-xs uppercase tracking-wider text-slate-600 flex items-center justify-between border-b border-slate-200/80 pb-1.5">
|
||||
<span>{category}</span>
|
||||
<span className="text-slate-400 font-normal lowercase">({items.length} items)</span>
|
||||
</h3>
|
||||
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-xs bg-white">
|
||||
<table className="w-full text-xs text-left border-collapse">
|
||||
<thead className="bg-slate-50 border-b border-slate-100 text-slate-600 font-semibold">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 w-10"></th>
|
||||
<th className="px-4 py-2.5 w-32 whitespace-nowrap">SKU</th>
|
||||
<th className="px-4 py-2.5 min-w-[200px] w-full">Material Name</th>
|
||||
<th className="px-4 py-2.5 w-28 whitespace-nowrap">Unit</th>
|
||||
<th className="px-4 py-2.5 w-36 whitespace-nowrap text-right">Standard Unit Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{items.map(item => {
|
||||
const isSelected = selectedMaterials.some(m => m.id === item.id);
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`transition-colors cursor-pointer ${
|
||||
isSelected ? 'bg-indigo-50/70 hover:bg-indigo-100/70' : 'hover:bg-slate-50/60'
|
||||
}`}
|
||||
onClick={() => toggleSelection(item)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
readOnly
|
||||
className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600 focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{item.sku ? (
|
||||
<Badge variant="secondary" className="font-mono text-[10px] text-slate-600 bg-slate-100 hover:bg-slate-200">
|
||||
{item.sku}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-slate-300">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-800">{item.name}</span>
|
||||
{isSelected && (
|
||||
<Badge className="bg-indigo-100 text-indigo-800 border-none text-[10px] font-medium flex items-center gap-0.5">
|
||||
<Check className="h-2.5 w-2.5" /> Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-600">
|
||||
{item.unit}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-bold text-slate-800">
|
||||
{parseFloat(item.unit_cost) > 0 ? formatCurrency(item.unit_cost) : <span className="text-slate-300 font-normal">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{items.map(item => {
|
||||
const isSelected = selectedMaterials.some(m => m.id === item.id);
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`transition-colors group cursor-pointer ${isSelected ? 'bg-indigo-50/70 hover:bg-indigo-100/70' : 'hover:bg-gray-50/60'}`}
|
||||
onClick={() => toggleSelection(item)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
readOnly
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{item.sku ? <Badge variant="secondary" className="font-mono text-[11px] text-gray-600 bg-gray-100 hover:bg-gray-200">{item.sku}</Badge> : <span className="text-gray-300">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-gray-900">{item.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-600">
|
||||
{item.unit}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-gray-700">
|
||||
{parseFloat(item.unit_cost) > 0 ? `₱${parseFloat(item.unit_cost).toFixed(2)}` : <span className="text-gray-300">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* TAB 2: ASSEMBLIES & KITS */}
|
||||
<TabsContent value="kits" className="flex-1 overflow-y-auto p-6 min-h-0 m-0 space-y-4">
|
||||
<div className="bg-gradient-to-r from-indigo-900 to-slate-900 text-white p-4 rounded-xl shadow-sm border border-indigo-700/50 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="font-bold text-sm text-indigo-100 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-indigo-400" /> Predefined Material Assembly Packages & Kits
|
||||
</h4>
|
||||
<p className="text-xs text-indigo-200/80 mt-0.5">
|
||||
Instantly bundle standardized BOM material groups for fast bulk requisitioning.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!materialGroups || materialGroups.length === 0) ? (
|
||||
<div className="text-center py-12 text-slate-400 text-xs">
|
||||
<Layers className="h-12 w-12 text-slate-300 mx-auto mb-3" />
|
||||
<h4 className="text-slate-600 font-medium text-sm">No Assembly Kits Found</h4>
|
||||
<p className="text-xs text-slate-400 mt-1">Assemblies are configured in Master Data to populate requisitions quickly.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{materialGroups.map(group => {
|
||||
const materialsCount = group.materials?.length || 0;
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-white border border-slate-200 rounded-xl p-4 shadow-xs hover:border-indigo-300 hover:shadow-md transition-all flex flex-col justify-between space-y-3"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<Badge variant="outline" className="text-[10px] font-semibold bg-indigo-50 text-indigo-700 border-indigo-100">
|
||||
Assembly Kit
|
||||
</Badge>
|
||||
<span className="text-[11px] font-medium text-slate-500 font-mono">
|
||||
{materialsCount} materials
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="font-bold text-sm text-slate-800">{group.name}</h4>
|
||||
{group.description && (
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">{group.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 bg-slate-50 p-3 rounded-lg border border-slate-100 space-y-1.5">
|
||||
<p className="text-[10px] font-bold text-slate-600 uppercase tracking-wider mb-1">
|
||||
Included Materials:
|
||||
</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{group.materials?.length > 0 ? (
|
||||
group.materials.slice(0, 4).map(m => (
|
||||
<li key={m.id} className="flex justify-between items-center text-slate-700">
|
||||
<span className="truncate pr-2 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0"></span>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="text-slate-500 font-mono text-[11px] shrink-0">{m.pivot?.quantity || 1} {m.unit}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-slate-400 italic">No materials assigned</li>
|
||||
)}
|
||||
{group.materials?.length > 4 && (
|
||||
<li className="text-indigo-600 text-[11px] font-semibold pt-0.5">
|
||||
+ {group.materials.length - 4} more materials
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="kits" className="m-0 p-6">
|
||||
{(!materialGroups || materialGroups.length === 0) ? (
|
||||
<div className="text-center py-12">
|
||||
<Layers className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<h4 className="text-gray-600 font-medium">No Assembly Kits Found</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">Assemblies are created by estimators to quickly populate requisitions.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{materialGroups.map(group => (
|
||||
<div key={group.id} className="border rounded-lg p-4 bg-white flex flex-col">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{group.name}</h3>
|
||||
{group.description && <p className="text-sm text-gray-500 line-clamp-2">{group.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-md p-3 mb-4 flex-1">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Includes:</h4>
|
||||
<ul className="text-sm space-y-1">
|
||||
{group.materials?.length > 0 ? (
|
||||
group.materials.slice(0, 4).map(m => (
|
||||
<li key={m.id} className="flex justify-between">
|
||||
<span className="text-gray-700 line-clamp-1">{m.name}</span>
|
||||
<span className="text-gray-500 ml-2 whitespace-nowrap">{m.pivot?.quantity || 1} {m.unit}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-gray-400 italic">No materials assigned</li>
|
||||
)}
|
||||
{group.materials?.length > 4 && (
|
||||
<li className="text-indigo-600 text-xs mt-1">+ {group.materials.length - 4} more items</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-auto pt-2 border-t">
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-slate-100 mt-auto">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 block mb-1">Quantity Multiplier</label>
|
||||
<label className="text-[10px] font-semibold text-slate-500 block mb-1">Qty Multiplier</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
step="1"
|
||||
value={kitQuantity[group.ulid] || '1'}
|
||||
onChange={e => setKitQuantity({...kitQuantity, [group.ulid]: e.target.value})}
|
||||
className="h-8"
|
||||
className="h-8 text-xs font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-5"
|
||||
className="mt-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs h-8 px-3 shadow-xs"
|
||||
onClick={() => {
|
||||
const multi = parseFloat(kitQuantity[group.ulid] || '1');
|
||||
if (multi > 0 && group.materials?.length > 0) {
|
||||
@@ -245,38 +353,40 @@ export function MaterialCatalogModal({
|
||||
}}
|
||||
disabled={!group.materials || group.materials.length === 0}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Assembly
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add Kit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Floating Bottom Selection Bar */}
|
||||
{selectedMaterials.length > 0 && (
|
||||
<div className="bg-white border-t px-6 py-4 flex items-center justify-between shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 text-sm py-1 px-3">
|
||||
<div className="bg-white border-t border-slate-200 px-6 py-3.5 flex items-center justify-between shadow-[0_-4px_12px_rgba(0,0,0,0.05)] z-20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs py-1 px-3 font-semibold">
|
||||
{selectedMaterials.length} Selected
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-600">materials ready to be added</span>
|
||||
<span className="text-xs text-slate-600">materials ready to add to requisition</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" onClick={() => setSelectedMaterials([])}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="text-xs text-slate-500 hover:text-slate-800" onClick={() => setSelectedMaterials([])}>
|
||||
Clear Selection
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs h-8 px-4 shadow-xs"
|
||||
onClick={() => {
|
||||
onSelectMaterials(selectedMaterials);
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add {selectedMaterials.length} Items to Requisition
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add {selectedMaterials.length} Items to Requisition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,6 +180,8 @@ class ProjectController extends Controller
|
||||
];
|
||||
|
||||
$estimationTotals = $this->getWizardTotals($project);
|
||||
$progressService = new \Modules\ProjectProgress\Services\ProjectProgressService();
|
||||
$evmData = $progressService->getEvmAnalysisData($project);
|
||||
|
||||
return Inertia::render('ProjectManagement::Projects/Overview', [
|
||||
'project' => $project,
|
||||
@@ -189,6 +191,7 @@ class ProjectController extends Controller
|
||||
'label' => $s->label(),
|
||||
]),
|
||||
'estimationTotals' => $estimationTotals,
|
||||
'evmData' => $evmData,
|
||||
'tab' => request()->query('tab', 'overview'),
|
||||
]);
|
||||
}
|
||||
@@ -683,6 +686,11 @@ class ProjectController extends Controller
|
||||
'labor.*.task_ulid' => 'required|string',
|
||||
'labor.*.labor_ulid' => 'required|string',
|
||||
'labor.*.estimated_hours' => 'required|numeric|min:0',
|
||||
'labor.*.allocation_type' => 'nullable|string|in:trade,bundle',
|
||||
'labor.*.bundle_name' => 'nullable|string|max:255',
|
||||
'labor.*.bundle_unit' => 'nullable|string|max:50',
|
||||
'labor.*.bundle_quantity' => 'nullable|numeric|min:0',
|
||||
'labor.*.bundle_unit_rate' => 'nullable|numeric|min:0',
|
||||
'team_ulids' => 'nullable|array',
|
||||
'team_ulids.*' => 'string|exists:teams,ulid',
|
||||
'user_ulids' => 'nullable|array',
|
||||
@@ -690,26 +698,34 @@ class ProjectController extends Controller
|
||||
]);
|
||||
|
||||
\DB::transaction(function () use ($project, $validated) {
|
||||
$existingLaborIds = [];
|
||||
$taskIds = $project->tasks()->pluck('id');
|
||||
\Modules\ProjectManagement\Models\TaskLabor::whereIn('task_id', $taskIds)->delete();
|
||||
|
||||
if (isset($validated['labor'])) {
|
||||
if (!empty($validated['labor'])) {
|
||||
foreach ($validated['labor'] as $lb) {
|
||||
$task = $project->tasks()->where('ulid', $lb['task_ulid'])->firstOrFail();
|
||||
$laborRecord = \Modules\Labors\Models\Labor::where('ulid', $lb['labor_ulid'])->firstOrFail();
|
||||
$task = $project->tasks()->where('ulid', $lb['task_ulid'])->first() ?? $project->tasks()->first();
|
||||
if (!$task) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$taskLabor = $task->taskLabors()->updateOrCreate(
|
||||
['labor_id' => $laborRecord->id],
|
||||
['estimated_hours' => $lb['estimated_hours']]
|
||||
);
|
||||
$existingLaborIds[] = $taskLabor->id;
|
||||
$laborRecord = \Modules\Labors\Models\Labor::where('ulid', $lb['labor_ulid'])->first();
|
||||
if (!$laborRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
\Modules\ProjectManagement\Models\TaskLabor::create([
|
||||
'task_id' => $task->id,
|
||||
'labor_id' => $laborRecord->id,
|
||||
'allocation_type' => $lb['allocation_type'] ?? 'trade',
|
||||
'bundle_name' => $lb['bundle_name'] ?? null,
|
||||
'bundle_unit' => $lb['bundle_unit'] ?? null,
|
||||
'bundle_quantity' => isset($lb['bundle_quantity']) ? (float) $lb['bundle_quantity'] : null,
|
||||
'bundle_unit_rate' => isset($lb['bundle_unit_rate']) ? (float) $lb['bundle_unit_rate'] : null,
|
||||
'estimated_hours' => round((float) $lb['estimated_hours'], 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$taskIds = $project->tasks()->pluck('id');
|
||||
\Modules\ProjectManagement\Models\TaskLabor::whereIn('task_id', $taskIds)
|
||||
->whereNotIn('id', $existingLaborIds)
|
||||
->delete();
|
||||
|
||||
// Synchronize project personnel pool
|
||||
$userIds = [];
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ class TaskLabor extends Model
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'labor_id',
|
||||
'allocation_type',
|
||||
'bundle_name',
|
||||
'bundle_unit',
|
||||
'bundle_quantity',
|
||||
'bundle_unit_rate',
|
||||
'estimated_hours',
|
||||
'actual_hours',
|
||||
];
|
||||
@@ -22,6 +27,8 @@ class TaskLabor extends Model
|
||||
return [
|
||||
'estimated_hours' => 'decimal:2',
|
||||
'actual_hours' => 'decimal:2',
|
||||
'bundle_quantity' => 'decimal:2',
|
||||
'bundle_unit_rate' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('task_labors', function (Blueprint $table) {
|
||||
$table->string('allocation_type')->default('trade')->after('labor_id');
|
||||
$table->string('bundle_name')->nullable()->after('allocation_type');
|
||||
$table->string('bundle_unit')->nullable()->after('bundle_name');
|
||||
$table->decimal('bundle_quantity', 10, 2)->nullable()->after('bundle_unit');
|
||||
$table->decimal('bundle_unit_rate', 10, 2)->nullable()->after('bundle_quantity');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('task_labors', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'allocation_type',
|
||||
'bundle_name',
|
||||
'bundle_unit',
|
||||
'bundle_quantity',
|
||||
'bundle_unit_rate',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -78,7 +78,7 @@ export default function EquipmentLookupModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[70vw] max-w-[95vw] max-h-[85vh] flex flex-col p-0 gap-0 border-slate-200">
|
||||
<DialogContent className="sm:max-w-[70vw] max-w-[95vw] max-h-[85vh] flex flex-col p-0 gap-0 border-slate-200 bg-white shadow-2xl overflow-hidden">
|
||||
<DialogHeader className="px-6 py-4 border-b border-slate-100">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-slate-800">
|
||||
<Wrench className="h-5 w-5 text-emerald-600" />
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { TrendingUp, Calendar, CheckCircle2, Clock, Activity, Flag, Layers, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface EvmPoint {
|
||||
date: string;
|
||||
full_date: string;
|
||||
is_today?: boolean;
|
||||
planned_pv: number;
|
||||
actual_ev: number | null;
|
||||
schedule_variance: number | null;
|
||||
spi: number | null;
|
||||
}
|
||||
|
||||
interface MilestoneTurnoverItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
weight_percentage: number;
|
||||
planned_date: string | null;
|
||||
actual_date: string | null;
|
||||
tasks_count: number;
|
||||
completed_tasks_count: number;
|
||||
progress_percentage: number;
|
||||
is_turnovered: boolean;
|
||||
turnover_status: string;
|
||||
turnover_status_color: 'emerald' | 'amber' | 'blue' | 'rose' | 'indigo' | 'slate';
|
||||
days_variance: number;
|
||||
}
|
||||
|
||||
interface EvmSummary {
|
||||
planned_pv: number;
|
||||
actual_ev: number;
|
||||
schedule_variance: number;
|
||||
spi: number;
|
||||
status: string;
|
||||
target_end_date: string;
|
||||
projected_end_date: string;
|
||||
days_variance: number;
|
||||
is_completed?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
evmData: {
|
||||
timeSeries: EvmPoint[];
|
||||
summary: EvmSummary;
|
||||
milestones?: MilestoneTurnoverItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function EvmSCurveChart({ evmData }: Props) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
if (!evmData || !evmData.timeSeries || evmData.timeSeries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { timeSeries, summary, milestones } = evmData;
|
||||
const width = 800;
|
||||
const height = 320;
|
||||
const padding = 45;
|
||||
|
||||
const maxVal = 100;
|
||||
const pointsCount = timeSeries.length;
|
||||
|
||||
const getX = (index: number) => padding + (index / Math.max(1, pointsCount - 1)) * (width - 2 * padding);
|
||||
const getY = (val: number) => height - padding - (Math.max(0, Math.min(100, val)) / maxVal) * (height - 2 * padding);
|
||||
|
||||
// 1. Build SVG path string for Planned PV
|
||||
const pvCoords = timeSeries.map((pt, i) => ({ x: getX(i), y: getY(pt.planned_pv), pt, idx: i }));
|
||||
const pvPath = pvCoords.reduce((acc, coord, i) => (i === 0 ? `M ${coord.x} ${coord.y}` : `${acc} L ${coord.x} ${coord.y}`), '');
|
||||
const pvAreaPath = `${pvPath} L ${getX(pointsCount - 1)} ${getY(0)} L ${getX(0)} ${getY(0)} Z`;
|
||||
|
||||
// 2. Build SVG path string for Actual EV
|
||||
const actualPoints = timeSeries
|
||||
.map((pt, idx) => ({ pt, idx, x: getX(idx), y: getY(pt.actual_ev ?? 0) }))
|
||||
.filter(item => item.pt.actual_ev !== null && item.pt.actual_ev !== undefined);
|
||||
|
||||
// If actualPoints does not start at index 0, anchor it to (0, 0%)
|
||||
const evDrawCoords: { x: number; y: number; pt: EvmPoint; idx: number }[] = [];
|
||||
if (actualPoints.length > 0) {
|
||||
if (actualPoints[0].idx > 0) {
|
||||
evDrawCoords.push({ x: getX(0), y: getY(0), pt: { ...timeSeries[0], actual_ev: 0 }, idx: 0 });
|
||||
}
|
||||
evDrawCoords.push(...actualPoints);
|
||||
}
|
||||
|
||||
const evPath = evDrawCoords.reduce((acc, coord, i) => (i === 0 ? `M ${coord.x} ${coord.y}` : `${acc} L ${coord.x} ${coord.y}`), '');
|
||||
const lastEvCoord = evDrawCoords.length > 0 ? evDrawCoords[evDrawCoords.length - 1] : null;
|
||||
const evAreaPath = evDrawCoords.length > 0
|
||||
? `${evPath} L ${lastEvCoord?.x} ${getY(0)} L ${evDrawCoords[0].x} ${getY(0)} Z`
|
||||
: '';
|
||||
|
||||
const isAhead = summary.schedule_variance >= 0;
|
||||
const statusColor = isAhead
|
||||
? 'bg-emerald-100 text-emerald-800 border-emerald-300'
|
||||
: summary.schedule_variance > -5
|
||||
? 'bg-amber-100 text-amber-800 border-amber-300'
|
||||
: 'bg-rose-100 text-rose-800 border-rose-300';
|
||||
|
||||
const activePoint = hoveredIdx !== null ? timeSeries[hoveredIdx] : null;
|
||||
|
||||
const getStatusBadgeClass = (color: string) => {
|
||||
switch (color) {
|
||||
case 'emerald': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
||||
case 'blue': return 'bg-blue-50 text-blue-700 border-blue-200';
|
||||
case 'amber': return 'bg-amber-50 text-amber-700 border-amber-200';
|
||||
case 'rose': return 'bg-rose-50 text-rose-700 border-rose-200';
|
||||
case 'indigo': return 'bg-indigo-50 text-indigo-700 border-indigo-200';
|
||||
default: return 'bg-slate-50 text-slate-700 border-slate-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Executive KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="shadow-sm border-slate-200/80 hover:border-blue-300 transition-colors">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Scheduled Baseline (PV)</p>
|
||||
<h3 className="text-2xl font-black text-slate-900 mt-1">{summary.planned_pv}%</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Estimated progress as of today</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 text-blue-600 rounded-xl border border-blue-100">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm border-slate-200/80 hover:border-emerald-300 transition-colors">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Real-Time Progress (EV)</p>
|
||||
<h3 className="text-2xl font-black text-emerald-600 mt-1">{summary.actual_ev}%</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Verified actual field progress</p>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl border border-emerald-100">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm border-slate-200/80 hover:border-slate-300 transition-colors">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Schedule Performance (SPI)</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<h3 className="text-2xl font-black text-slate-900">{summary.spi}</h3>
|
||||
<Badge variant="outline" className={`text-[11px] font-semibold px-2 py-0.5 border ${statusColor}`}>
|
||||
{summary.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Schedule Variance: <strong className={summary.schedule_variance >= 0 ? "text-emerald-600" : "text-rose-600"}>
|
||||
{summary.schedule_variance > 0 ? `+${summary.schedule_variance}%` : `${summary.schedule_variance}%`}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl border ${isAhead ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-rose-50 text-rose-600 border-rose-100'}`}>
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm border-slate-200/80 hover:border-amber-300 transition-colors">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{summary.is_completed ? 'Actual vs Target End' : 'Target vs Forecasted End'}
|
||||
</p>
|
||||
<h3 className="text-base font-bold text-slate-900 mt-1">{summary.projected_end_date}</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Target: {summary.target_end_date}{' '}
|
||||
{summary.days_variance > 0 ? (
|
||||
<span className="text-rose-600 font-semibold">(+{summary.days_variance}d delay)</span>
|
||||
) : summary.days_variance < 0 ? (
|
||||
<span className="text-emerald-600 font-semibold">({Math.abs(summary.days_variance)}d ahead)</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-semibold">(On schedule)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl border ${summary.is_completed ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-amber-50 text-amber-600 border-amber-100'}`}>
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Interactive S-Curve Visual Card */}
|
||||
<Card className="shadow-sm border-slate-200 overflow-hidden">
|
||||
<CardHeader className="bg-slate-50/50 border-b border-slate-100 pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
Earned Value S-Curve Baseline Comparison
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Contrasting Planned Schedule Curve (PV) against Verified Real-Time Field Accomplishments (EV)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs font-medium bg-white px-3 py-1.5 rounded-lg border border-slate-200 shadow-2xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-1.5 rounded-full bg-blue-500 inline-block"></span>
|
||||
<span className="text-slate-700">Baseline Target (PV)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-emerald-500 border-2 border-white shadow-xs inline-block"></span>
|
||||
<span className="text-slate-700 font-semibold">Real-Time Actuals (EV)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 relative">
|
||||
{/* Hover Tooltip Overlay */}
|
||||
{activePoint && (
|
||||
<div
|
||||
className="absolute top-8 z-20 bg-slate-900/90 backdrop-blur-sm text-white px-3.5 py-2.5 rounded-xl shadow-lg border border-slate-700 text-xs pointer-events-none transition-all duration-150"
|
||||
style={{
|
||||
left: `${Math.min(width - 160, Math.max(padding + 20, getX(hoveredIdx ?? 0)))}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div className="font-bold text-slate-200 border-b border-slate-700 pb-1 mb-1.5 flex justify-between gap-4">
|
||||
<span>{activePoint.full_date}</span>
|
||||
{activePoint.is_today && <span className="text-emerald-400 font-semibold">[Today]</span>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-blue-300">Planned (PV):</span>
|
||||
<span className="font-bold">{activePoint.planned_pv}%</span>
|
||||
</div>
|
||||
{activePoint.actual_ev !== null && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-emerald-300">Verified (EV):</span>
|
||||
<span className="font-bold">{activePoint.actual_ev}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activePoint.schedule_variance !== null && (
|
||||
<div className="flex justify-between gap-4 text-[11px] text-slate-300">
|
||||
<span>Variance (SV):</span>
|
||||
<span className={activePoint.schedule_variance >= 0 ? "text-emerald-400" : "text-rose-400"}>
|
||||
{activePoint.schedule_variance > 0 ? `+${activePoint.schedule_variance}%` : `${activePoint.schedule_variance}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto min-w-[650px] max-h-[380px]">
|
||||
<defs>
|
||||
<linearGradient id="pvGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="evGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#10b981" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#10b981" stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Horizontal Gridlines */}
|
||||
{[0, 25, 50, 75, 100].map(val => (
|
||||
<g key={val}>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={getY(val)}
|
||||
x2={width - padding}
|
||||
y2={getY(val)}
|
||||
stroke="#e2e8f0"
|
||||
strokeDasharray={val === 0 ? "none" : "3 3"}
|
||||
strokeWidth={val === 0 ? "1.5" : "1"}
|
||||
/>
|
||||
<text x={padding - 10} y={getY(val) + 4} textAnchor="end" className="text-[11px] fill-slate-400 font-mono font-medium">
|
||||
{val}%
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Today Vertical Line Indicator */}
|
||||
{timeSeries.map((pt, idx) => {
|
||||
if (!pt.is_today) return null;
|
||||
const todayX = getX(idx);
|
||||
return (
|
||||
<g key="today-indicator">
|
||||
<line
|
||||
x1={todayX}
|
||||
y1={padding - 10}
|
||||
x2={todayX}
|
||||
y2={height - padding}
|
||||
stroke="#10b981"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity="0.8"
|
||||
/>
|
||||
<rect
|
||||
x={todayX - 22}
|
||||
y={padding - 22}
|
||||
width="44"
|
||||
height="16"
|
||||
rx="4"
|
||||
fill="#10b981"
|
||||
/>
|
||||
<text
|
||||
x={todayX}
|
||||
y={padding - 10}
|
||||
textAnchor="middle"
|
||||
className="text-[9px] font-bold fill-white tracking-wider uppercase"
|
||||
>
|
||||
Today
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area Fills */}
|
||||
{pvAreaPath && <path d={pvAreaPath} fill="url(#pvGradient)" />}
|
||||
{evAreaPath && <path d={evAreaPath} fill="url(#evGradient)" />}
|
||||
|
||||
{/* Planned Baseline PV Path */}
|
||||
<path
|
||||
d={pvPath}
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray="5 3"
|
||||
/>
|
||||
|
||||
{/* Real-Time Actuals EV Path */}
|
||||
{evPath && (
|
||||
<path
|
||||
d={evPath}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Planned PV Data Points */}
|
||||
{pvCoords.map((coord) => (
|
||||
<circle
|
||||
key={`pv-${coord.pt.full_date}`}
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r={hoveredIdx === coord.idx ? "4.5" : "3"}
|
||||
className="fill-blue-500 stroke-white stroke-1.5 transition-all"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Real-Time Actuals EV Data Points */}
|
||||
{evDrawCoords.map((coord) => (
|
||||
<g key={`ev-${coord.pt.full_date}`}>
|
||||
{coord.pt.is_today && (
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="10"
|
||||
className="fill-emerald-400/20 animate-ping"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r={coord.pt.is_today ? "6" : hoveredIdx === coord.idx ? "5" : "4"}
|
||||
className="fill-emerald-600 stroke-white stroke-2 shadow-md cursor-pointer transition-all"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Interactive Hover Columns */}
|
||||
{timeSeries.map((pt, idx) => (
|
||||
<rect
|
||||
key={`hover-${pt.full_date}`}
|
||||
x={getX(idx) - (width / pointsCount) / 2}
|
||||
y={padding}
|
||||
width={width / pointsCount}
|
||||
height={height - 2 * padding}
|
||||
fill="transparent"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHoveredIdx(idx)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* X-Axis Dates */}
|
||||
{timeSeries.map((pt, idx) => (
|
||||
<text
|
||||
key={`label-${pt.full_date}`}
|
||||
x={getX(idx)}
|
||||
y={height - 15}
|
||||
textAnchor="middle"
|
||||
className={`text-[10px] font-medium font-mono ${pt.is_today ? 'fill-emerald-600 font-bold' : hoveredIdx === idx ? 'fill-slate-900 font-semibold' : 'fill-slate-400'}`}
|
||||
>
|
||||
{pt.date}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Milestone Turnover & Stage Gate Delivery Coordination Card */}
|
||||
{milestones && milestones.length > 0 && (
|
||||
<Card className="shadow-sm border-slate-200 overflow-hidden">
|
||||
<CardHeader className="bg-slate-50/50 border-b border-slate-100 pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold text-slate-800 flex items-center gap-2">
|
||||
<Flag className="w-4 h-4 text-emerald-600" />
|
||||
Milestone Turnover & Stage Gate Coordination
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-slate-500">
|
||||
Tasks drive granular field progress, while milestones govern stage-gate turnover and handover deadlines.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-white text-slate-700 border-slate-200 text-xs font-semibold px-2.5 py-1">
|
||||
{milestones.filter(m => m.is_turnovered).length} of {milestones.length} Turnovered
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 divide-y divide-slate-100">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-slate-50 text-slate-500 font-semibold uppercase tracking-wider text-[10px]">
|
||||
<tr>
|
||||
<th className="px-5 py-3">Milestone Stage</th>
|
||||
<th className="px-4 py-3 text-center">Weight</th>
|
||||
<th className="px-4 py-3">Task Completion</th>
|
||||
<th className="px-4 py-3">Scheduled Turnover</th>
|
||||
<th className="px-4 py-3">Actual Turnover</th>
|
||||
<th className="px-5 py-3 text-right">Gate Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white font-medium text-slate-700">
|
||||
{milestones.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-slate-50/70 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<Layers className="w-3.5 h-3.5 text-slate-400" />
|
||||
{m.name}
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 truncate max-w-xs">{m.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-center font-bold text-slate-800">
|
||||
{m.weight_percentage}%
|
||||
</td>
|
||||
<td className="px-4 py-3.5 min-w-[160px]">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<span className="text-slate-500 font-mono">
|
||||
{m.completed_tasks_count}/{m.tasks_count} tasks
|
||||
</span>
|
||||
<span className="font-bold text-slate-900">{m.progress_percentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 rounded-full ${
|
||||
m.progress_percentage >= 100 ? 'bg-emerald-500' : m.progress_percentage > 0 ? 'bg-blue-500' : 'bg-slate-300'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, m.progress_percentage))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-slate-600">
|
||||
{m.planned_date || <span className="text-slate-400 italic">Not set</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-slate-600">
|
||||
{m.actual_date ? (
|
||||
<span className="text-emerald-700 font-semibold">{m.actual_date}</span>
|
||||
) : (
|
||||
<span className="text-slate-400 italic">Pending Handover</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[11px] font-bold px-2.5 py-0.5 border ${getStatusBadgeClass(m.turnover_status_color)}`}
|
||||
>
|
||||
{m.turnover_status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export default function LaborLookupModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[80vw] max-w-[95vw] max-h-[90vh] flex flex-col p-0 gap-0 border-slate-200">
|
||||
<DialogContent className="sm:max-w-[80vw] max-w-[95vw] max-h-[90vh] flex flex-col p-0 gap-0 border-slate-200 bg-white shadow-2xl overflow-hidden">
|
||||
<DialogHeader className="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
||||
<Users className="h-5 w-5 text-emerald-600" />
|
||||
|
||||
@@ -199,10 +199,14 @@ export default function Index({ projects, history, drafts = [], filters, statuse
|
||||
</TableRow>
|
||||
) : (
|
||||
projects.data.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="truncate font-mono text-[11px]">{project.code}</TableCell>
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-blue-50/40 transition-colors group"
|
||||
onClick={() => router.visit(route('projects.show', project.ulid))}
|
||||
>
|
||||
<TableCell className="truncate font-mono text-[11px] group-hover:text-blue-600 font-semibold transition-colors">{project.code}</TableCell>
|
||||
<TableCell className="truncate font-medium">
|
||||
<div className="truncate" title={project.name}>{project.name}</div>
|
||||
<div className="truncate group-hover:text-blue-700 transition-colors" title={project.name}>{project.name}</div>
|
||||
{project.contractor && (
|
||||
<div className="text-[11px] text-blue-600 font-normal flex items-center gap-1 mt-0.5">
|
||||
<span>🏢 {project.contractor.company_name}</span>
|
||||
@@ -250,13 +254,8 @@ export default function Index({ projects, history, drafts = [], filters, statuse
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={route('projects.show', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{can('edit', 'projects') && (
|
||||
<Link href={route('projects.edit', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="Edit">
|
||||
@@ -318,9 +317,13 @@ export default function Index({ projects, history, drafts = [], filters, statuse
|
||||
</TableRow>
|
||||
) : (
|
||||
history.data.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-mono text-sm">{project.code}</TableCell>
|
||||
<TableCell className="font-medium">{project.name}</TableCell>
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-blue-50/40 transition-colors group"
|
||||
onClick={() => router.visit(route('projects.show', project.ulid))}
|
||||
>
|
||||
<TableCell className="font-mono text-sm group-hover:text-blue-600 font-semibold transition-colors">{project.code}</TableCell>
|
||||
<TableCell className="font-medium group-hover:text-blue-700 transition-colors">{project.name}</TableCell>
|
||||
<TableCell className="text-gray-500">{project.client_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(project.status)}>
|
||||
@@ -360,13 +363,8 @@ export default function Index({ projects, history, drafts = [], filters, statuse
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={route('projects.show', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{can('edit', 'projects') && (
|
||||
<Link href={route('projects.edit', project.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="Edit">
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import ProjectLayout from '../../../Layouts/ProjectLayout';
|
||||
import EvmSCurveChart from '../../../Components/EvmSCurveChart';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function Progress({ project, currentTab }: { project: any, currentTab: string }) {
|
||||
export default function Progress({ project, currentTab, evmData }: { project: any, currentTab: string, evmData?: any }) {
|
||||
if (!project) return <ProjectLayout project={null} currentTab={currentTab} children={null} />;
|
||||
|
||||
return (
|
||||
<ProjectLayout project={project} currentTab={currentTab}>
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto bg-emerald-100 text-emerald-600 p-3 rounded-full w-fit mb-4">
|
||||
<Construction className="w-8 h-8" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Progress Monitoring Module</CardTitle>
|
||||
<CardDescription>
|
||||
This module is currently being scaffolded.
|
||||
Soon you will be able to update quantities accomplished and track visual progress against the baseline.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-gray-500">
|
||||
Integration in progress. Please check back later.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
{evmData ? (
|
||||
<EvmSCurveChart evmData={evmData} />
|
||||
) : (
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl">Progress Monitoring Baseline</CardTitle>
|
||||
<CardDescription>
|
||||
Select a project to view the Earned Value Management baseline progress calculation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ProjectLayout from '../../Layouts/ProjectLayout';
|
||||
import EvmSCurveChart from '../../Components/EvmSCurveChart';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
|
||||
import { MapPin, Users, TrendingUp, DollarSign, Calendar, AlertTriangle, ArrowRight, Activity, ShieldCheck, FileText, ClipboardList, Hammer, Truck, Layers, Milestone as MilestoneIcon } from 'lucide-react';
|
||||
import { PageProps } from '@/types';
|
||||
@@ -99,6 +100,7 @@ interface Props extends PageProps {
|
||||
margin: number;
|
||||
margin_pct: number;
|
||||
};
|
||||
evmData?: any;
|
||||
tab?: string;
|
||||
}
|
||||
|
||||
@@ -123,7 +125,7 @@ function StatCard({ icon: Icon, label, value, sub }: { icon: React.ComponentType
|
||||
);
|
||||
}
|
||||
|
||||
export default function Overview({ project, taskStats, allowedTransitions, estimationTotals, tab }: Props) {
|
||||
export default function Overview({ project, taskStats, allowedTransitions, estimationTotals, tab, evmData }: Props) {
|
||||
const [activeTab, setActiveTab] = useState(tab || 'overview');
|
||||
|
||||
return (
|
||||
@@ -137,6 +139,12 @@ export default function Overview({ project, taskStats, allowedTransitions, estim
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" /> Overview Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="progress"
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" /> Progress Monitoring (EVM)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="estimation"
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium transition-all data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2"
|
||||
@@ -655,23 +663,103 @@ export default function Overview({ project, taskStats, allowedTransitions, estim
|
||||
<Hammer className="h-3.5 w-3.5 text-orange-500" /> Manpower Allocation
|
||||
</h5>
|
||||
{task.task_labors && task.task_labors.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{task.task_labors.map((tl) => (
|
||||
<div key={tl.id} className="flex justify-between items-center text-xs p-2 bg-slate-50/30 border border-slate-100 rounded-lg">
|
||||
<div>
|
||||
<span className="font-medium text-slate-800 block">{tl.labor?.name}</span>
|
||||
<span className="text-[10px] text-slate-400 capitalize">{tl.labor?.category} • {formatCurrency(tl.labor?.hourly_rate)}/hr</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-semibold text-slate-700 block">{tl.estimated_hours} hrs</span>
|
||||
<span className="text-[10px] font-bold text-slate-550">{formatCurrency(parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center text-xs font-semibold border-t border-slate-100 pt-2 px-1 text-slate-700">
|
||||
<span>Labor Subtotal</span>
|
||||
<span>{formatCurrency(taskLaborSubtotal)}</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{(() => {
|
||||
const bundles: { [key: string]: any[] } = {};
|
||||
const singleTrades: any[] = [];
|
||||
|
||||
task.task_labors.forEach((tl: any) => {
|
||||
if (tl.allocation_type === 'bundle' && tl.bundle_name) {
|
||||
if (!bundles[tl.bundle_name]) {
|
||||
bundles[tl.bundle_name] = [];
|
||||
}
|
||||
bundles[tl.bundle_name].push(tl);
|
||||
} else {
|
||||
singleTrades.push(tl);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Render Job Crew Bundles */}
|
||||
{Object.entries(bundles).map(([bundleName, items]) => {
|
||||
const first = items[0];
|
||||
const bundleSubtotal = items.reduce((sum, tl) =>
|
||||
sum + (parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0')), 0
|
||||
);
|
||||
const bundleUnit = first?.bundle_unit || 'm²';
|
||||
const bundleQty = first?.bundle_quantity ? Number(first.bundle_quantity) : null;
|
||||
const bundleRate = first?.bundle_unit_rate ? Number(first.bundle_unit_rate) : null;
|
||||
|
||||
return (
|
||||
<div key={bundleName} className="p-2.5 bg-indigo-50/40 border border-indigo-100 rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-indigo-100/70 pb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 text-indigo-600 shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-xs text-slate-900 block leading-tight">{bundleName}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-[9px] px-1 py-0 font-semibold uppercase">
|
||||
Crew Bundle
|
||||
</Badge>
|
||||
{bundleQty !== null && (
|
||||
<span className="text-[10px] text-slate-500 font-medium">
|
||||
{bundleQty} {bundleUnit} {bundleRate ? `@ ${formatCurrency(bundleRate)}/${bundleUnit}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="font-bold text-xs text-indigo-950 font-mono block">
|
||||
{formatCurrency(bundleSubtotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Itemized Trades Breakdown within bundle */}
|
||||
<div className="pl-2 space-y-1 border-l-2 border-indigo-200">
|
||||
{items.map((tl: any) => (
|
||||
<div key={tl.id} className="flex justify-between items-center text-[11px] text-slate-600">
|
||||
<span>
|
||||
{tl.labor?.name} <span className="text-[10px] text-slate-400">({tl.labor?.category} • {formatCurrency(tl.labor?.hourly_rate)}/hr)</span>
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-700">
|
||||
{tl.estimated_hours} hrs ({formatCurrency(parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0'))})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render Single Trades */}
|
||||
{singleTrades.map((tl: any) => (
|
||||
<div key={tl.id} className="flex justify-between items-center text-xs p-2 bg-slate-50/30 border border-slate-100 rounded-lg">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-slate-800">{tl.labor?.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 text-emerald-700 bg-emerald-50 border-emerald-100 font-normal uppercase">
|
||||
Trade
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 capitalize">{tl.labor?.category} • {formatCurrency(tl.labor?.hourly_rate)}/hr</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-semibold text-slate-700 block">{tl.estimated_hours} hrs</span>
|
||||
<span className="text-[10px] font-bold text-slate-550">{formatCurrency(parseFloat(tl.estimated_hours as string) * parseFloat(tl.labor?.hourly_rate as string || '0'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between items-center text-xs font-semibold border-t border-slate-100 pt-2 px-1 text-slate-700">
|
||||
<span>Labor Subtotal</span>
|
||||
<span>{formatCurrency(taskLaborSubtotal)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic py-2">No labor allocated to this task.</p>
|
||||
@@ -755,6 +843,21 @@ export default function Overview({ project, taskStats, allowedTransitions, estim
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="progress" className="mt-0 space-y-6 focus-visible:outline-none">
|
||||
{evmData ? (
|
||||
<EvmSCurveChart evmData={evmData} />
|
||||
) : (
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl">Progress Monitoring Baseline</CardTitle>
|
||||
<CardDescription>
|
||||
No EVM progress data is currently available for this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Form, FormField } from '@/Components/ui/form';
|
||||
import MaterialPickerModal from '@/Components/MaterialPickerModal';
|
||||
import EvmSCurveChart from '../../Components/EvmSCurveChart';
|
||||
import {
|
||||
ArrowLeft, Pencil, Plus, Trash2, UserPlus, UserMinus, Play, CheckCircle2,
|
||||
MapPin, Calendar, DollarSign, TrendingUp, Users, ClipboardList, Eye,
|
||||
@@ -135,6 +136,7 @@ interface Props extends PageProps {
|
||||
delayReasons: DelayReasonOption[];
|
||||
statuses: StatusOption[];
|
||||
allowedTransitions: StatusOption[];
|
||||
evmData?: any;
|
||||
}
|
||||
|
||||
const statusLabel = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
@@ -183,7 +185,7 @@ function reportTotalIncidents(hse?: ReportHse): number {
|
||||
+ hse.medical_cases + hse.near_misses + hse.environmental_damage + hse.property_damage;
|
||||
}
|
||||
|
||||
export default function Show({ project, employees, availableMaterials, latestReports, taskStats, milestones, milestoneStats, weatherConditions, delayReasons, allowedTransitions }: Props) {
|
||||
export default function Show({ project, employees, availableMaterials, latestReports, taskStats, milestones, milestoneStats, weatherConditions, delayReasons, allowedTransitions, evmData }: Props) {
|
||||
const { flash } = usePage<PageProps>().props;
|
||||
const { can } = usePermission();
|
||||
const [addTaskOpen, setAddTaskOpen] = useState(false);
|
||||
@@ -368,6 +370,9 @@ export default function Show({ project, employees, availableMaterials, latestRep
|
||||
<Tabs defaultValue="tasks">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="progress">
|
||||
<TrendingUp className="mr-1 h-4 w-4" /> Progress (EVM)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="timeline">
|
||||
<Timer className="mr-1 h-4 w-4" /> Timeline
|
||||
</TabsTrigger>
|
||||
@@ -1524,6 +1529,22 @@ export default function Show({ project, employees, availableMaterials, latestRep
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Progress EVM Tab */}
|
||||
<TabsContent value="progress">
|
||||
{evmData ? (
|
||||
<EvmSCurveChart evmData={evmData} />
|
||||
) : (
|
||||
<Card className="border-dashed border-2 bg-gray-50/50">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl">Progress Monitoring Baseline</CardTitle>
|
||||
<CardDescription>
|
||||
No EVM progress calculation data is currently available for this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Textarea } from '@/Components/ui/textarea';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Plus, Trash2, ClipboardList, Wrench, Users,
|
||||
TrendingUp, Check, CheckCircle2, AlertTriangle, ShieldCheck, ArrowRight, Sparkles,
|
||||
FileSpreadsheet, FileDown
|
||||
FileSpreadsheet, FileDown, HardHat, Layers
|
||||
} from 'lucide-react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { PageProps } from '@/types';
|
||||
@@ -108,36 +108,67 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
|
||||
const handleSelectLabor = (labor: Labor) => {
|
||||
if (activeLaborRowIdx !== null) {
|
||||
updateLaborAllocation(activeLaborRowIdx, 'labor_ulid', labor.ulid);
|
||||
setLocalLabor(prev => prev.map((item, i) => i === activeLaborRowIdx ? {
|
||||
...item,
|
||||
type: 'trade',
|
||||
labor_ulid: labor.ulid,
|
||||
name: labor.name,
|
||||
category: labor.category,
|
||||
unit: 'hrs',
|
||||
unit_rate: Number(labor.hourly_rate),
|
||||
quantity: item.quantity || item.estimated_hours || '40',
|
||||
estimated_hours: item.quantity || item.estimated_hours || '40'
|
||||
} : item));
|
||||
} else {
|
||||
setLocalLabor(prev => [
|
||||
...prev,
|
||||
{
|
||||
type: 'trade',
|
||||
task_ulid: 'general',
|
||||
labor_ulid: labor.ulid,
|
||||
name: labor.name,
|
||||
category: labor.category,
|
||||
unit: 'hrs',
|
||||
unit_rate: Number(labor.hourly_rate),
|
||||
quantity: '40',
|
||||
estimated_hours: '40'
|
||||
}
|
||||
]);
|
||||
}
|
||||
setLaborModalOpen(false);
|
||||
setActiveLaborRowIdx(null);
|
||||
};
|
||||
|
||||
const handleAddLaborBundle = (bundle: any, crewMultiplier: number) => {
|
||||
const newAllocations: any[] = [];
|
||||
bundle.items.forEach((item: any) => {
|
||||
const matched = labors.find(l =>
|
||||
l.name.toLowerCase().includes(item.labor_name.toLowerCase()) ||
|
||||
item.labor_name.toLowerCase().includes(l.name.toLowerCase())
|
||||
);
|
||||
if (matched) {
|
||||
newAllocations.push({
|
||||
task_ulid: '',
|
||||
labor_ulid: matched.ulid,
|
||||
estimated_hours: String(item.default_hours * crewMultiplier)
|
||||
});
|
||||
} else if (labors.length > 0) {
|
||||
newAllocations.push({
|
||||
task_ulid: '',
|
||||
labor_ulid: labors[0].ulid,
|
||||
estimated_hours: String(item.default_hours * crewMultiplier)
|
||||
});
|
||||
}
|
||||
});
|
||||
const ratePerSqm = bundle.items.reduce((sum: number, item: any) => sum + (item.rate_per_sqm || item.estimated_hourly_rate || 0), 0);
|
||||
const sqmArea = crewMultiplier || 10;
|
||||
|
||||
const newBundleData = {
|
||||
type: 'bundle',
|
||||
bundle_id: bundle.id,
|
||||
name: bundle.name,
|
||||
job_type: bundle.job_type,
|
||||
category: bundle.category,
|
||||
unit: 'm²',
|
||||
unit_rate: ratePerSqm,
|
||||
quantity: String(sqmArea),
|
||||
bundle_items: bundle.items
|
||||
};
|
||||
|
||||
if (newAllocations.length > 0) {
|
||||
setLocalLabor(prev => [...prev, ...newAllocations]);
|
||||
if (activeLaborRowIdx !== null) {
|
||||
setLocalLabor(prev => prev.map((item, i) => i === activeLaborRowIdx ? {
|
||||
...item,
|
||||
...newBundleData,
|
||||
task_ulid: item.task_ulid || 'general'
|
||||
} : item));
|
||||
} else {
|
||||
setLocalLabor(prev => [
|
||||
...prev,
|
||||
{
|
||||
...newBundleData,
|
||||
task_ulid: 'general'
|
||||
}
|
||||
]);
|
||||
}
|
||||
setLaborModalOpen(false);
|
||||
setActiveLaborRowIdx(null);
|
||||
@@ -203,12 +234,47 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
const list: any[] = [];
|
||||
project.tasks.forEach(t => {
|
||||
if (t.task_labors && t.task_labors.length > 0) {
|
||||
const bundleGroups: { [key: string]: any[] } = {};
|
||||
|
||||
t.task_labors.forEach((tl: any) => {
|
||||
if (tl.allocation_type === 'bundle' && tl.bundle_name) {
|
||||
const bKey = `${t.ulid}_${tl.bundle_name}`;
|
||||
if (!bundleGroups[bKey]) {
|
||||
bundleGroups[bKey] = [];
|
||||
}
|
||||
bundleGroups[bKey].push(tl);
|
||||
} else {
|
||||
list.push({
|
||||
type: 'trade',
|
||||
task_ulid: t.ulid,
|
||||
task_name: t.name,
|
||||
labor_ulid: tl.labor?.ulid || '',
|
||||
name: tl.labor?.name || 'Labor Trade',
|
||||
category: tl.labor?.category || 'skilled',
|
||||
unit: 'hrs',
|
||||
unit_rate: Number(tl.labor?.hourly_rate || 0),
|
||||
quantity: String(tl.estimated_hours),
|
||||
estimated_hours: String(tl.estimated_hours)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(bundleGroups).forEach(group => {
|
||||
const first = group[0];
|
||||
list.push({
|
||||
type: 'bundle',
|
||||
task_ulid: t.ulid,
|
||||
task_name: t.name,
|
||||
labor_ulid: tl.labor?.ulid || '',
|
||||
estimated_hours: String(tl.estimated_hours)
|
||||
name: first.bundle_name,
|
||||
unit: first.bundle_unit || 'm²',
|
||||
quantity: String(first.bundle_quantity || 10),
|
||||
unit_rate: Number(first.bundle_unit_rate || 0),
|
||||
bundle_items: group.map((tl: any) => ({
|
||||
labor_name: tl.labor?.name || 'Trade',
|
||||
category: tl.labor?.category || 'skilled',
|
||||
rate_per_sqm: Number(tl.bundle_unit_rate || 0) / group.length,
|
||||
estimated_hourly_rate: Number(tl.labor?.hourly_rate || 0)
|
||||
}))
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -271,9 +337,16 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
|
||||
const calculatedLaborCost = useMemo(() => {
|
||||
return localLabor.reduce((sum, item) => {
|
||||
const rateObj = labors.find(r => r.ulid === item.labor_ulid);
|
||||
const rate = rateObj ? Number(rateObj.hourly_rate) : 0;
|
||||
return sum + (Number(item.estimated_hours) * rate);
|
||||
if (item.type === 'bundle') {
|
||||
const rate = Number(item.unit_rate || 0);
|
||||
const area = Number(item.quantity || 0);
|
||||
return sum + (area * rate);
|
||||
} else {
|
||||
const rateObj = labors.find(r => r.ulid === item.labor_ulid);
|
||||
const rate = rateObj ? Number(rateObj.hourly_rate) : Number(item.unit_rate || 0);
|
||||
const hours = Number(item.quantity || item.estimated_hours || 0);
|
||||
return sum + (hours * rate);
|
||||
}
|
||||
}, 0);
|
||||
}, [localLabor, labors]);
|
||||
|
||||
@@ -334,8 +407,63 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
};
|
||||
|
||||
const handleSaveLabor = () => {
|
||||
const flattenedLabor: any[] = [];
|
||||
|
||||
localLabor.forEach(item => {
|
||||
const targetTaskUlid = (item.task_ulid && item.task_ulid !== 'general')
|
||||
? item.task_ulid
|
||||
: (project.tasks[0]?.ulid || '');
|
||||
|
||||
if (!targetTaskUlid) return;
|
||||
|
||||
if (item.type === 'bundle') {
|
||||
const area = Number(item.quantity || 0);
|
||||
const bundleItems = item.bundle_items || [];
|
||||
const bundleRate = Number(item.unit_rate || 0);
|
||||
|
||||
bundleItems.forEach((bItem: any) => {
|
||||
const matched = labors.find(l =>
|
||||
l.name.toLowerCase().includes(bItem.labor_name.toLowerCase()) ||
|
||||
bItem.labor_name.toLowerCase().includes(l.name.toLowerCase())
|
||||
) || (labors.find(l => l.category === bItem.category) || labors[0]);
|
||||
|
||||
if (matched) {
|
||||
const itemRatePerSqm = Number(bItem.rate_per_sqm || bItem.estimated_hourly_rate || 50);
|
||||
const itemCost = itemRatePerSqm * area;
|
||||
const hourlyRate = Number(matched.hourly_rate) || 100;
|
||||
const hours = itemCost / hourlyRate;
|
||||
|
||||
flattenedLabor.push({
|
||||
task_ulid: targetTaskUlid,
|
||||
labor_ulid: matched.ulid,
|
||||
allocation_type: 'bundle',
|
||||
bundle_name: item.name,
|
||||
bundle_unit: 'm²',
|
||||
bundle_quantity: String(area),
|
||||
bundle_unit_rate: String(bundleRate),
|
||||
estimated_hours: (Math.round(hours * 100) / 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (item.labor_ulid) {
|
||||
const rateObj = labors.find(r => r.ulid === item.labor_ulid);
|
||||
flattenedLabor.push({
|
||||
task_ulid: targetTaskUlid,
|
||||
labor_ulid: item.labor_ulid,
|
||||
allocation_type: 'trade',
|
||||
bundle_name: null,
|
||||
bundle_unit: 'hrs',
|
||||
bundle_quantity: String(item.quantity || item.estimated_hours || 0),
|
||||
bundle_unit_rate: String(rateObj ? rateObj.hourly_rate : (item.unit_rate || 0)),
|
||||
estimated_hours: String(item.quantity || item.estimated_hours || 0)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post(route('projects.wizard.labor', project.ulid), {
|
||||
labor: localLabor,
|
||||
labor: flattenedLabor,
|
||||
team_ulids: selectedTeamUlids,
|
||||
user_ulids: selectedUserUlids,
|
||||
}, {
|
||||
@@ -423,7 +551,16 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
|
||||
// Allocations Helpers
|
||||
const addLaborAllocation = () => {
|
||||
setLocalLabor([...localLabor, { task_ulid: '', labor_ulid: '', estimated_hours: '0' }]);
|
||||
setLocalLabor([...localLabor, {
|
||||
type: 'trade',
|
||||
task_ulid: 'general',
|
||||
labor_ulid: '',
|
||||
name: '',
|
||||
unit: 'hrs',
|
||||
unit_rate: 0,
|
||||
quantity: '40',
|
||||
estimated_hours: '40'
|
||||
}]);
|
||||
};
|
||||
|
||||
const removeLaborAllocation = (idx: number) => {
|
||||
@@ -431,7 +568,14 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
};
|
||||
|
||||
const updateLaborAllocation = (idx: number, field: string, val: string) => {
|
||||
setLocalLabor(localLabor.map((item, i) => i === idx ? { ...item, [field]: val } : item));
|
||||
setLocalLabor(localLabor.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
const updated = { ...item, [field]: val };
|
||||
if (field === 'quantity') {
|
||||
updated.estimated_hours = val;
|
||||
}
|
||||
return updated;
|
||||
}));
|
||||
};
|
||||
|
||||
const addEquipmentAllocation = () => {
|
||||
@@ -807,14 +951,25 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
{/* Step 4: Manpower Allocation */}
|
||||
{step === 4 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<CardHeader className="p-0 pb-4 border-b border-slate-100 flex flex-row items-center justify-between">
|
||||
<CardHeader className="p-0 pb-4 border-b border-slate-100 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-md font-semibold text-slate-800">Step 4: Manpower Rate Allocation</CardTitle>
|
||||
<CardDescription className="text-xs">Allocate standard labor trades and estimate total work hours required for scheduled tasks.</CardDescription>
|
||||
<CardDescription className="text-xs">Allocate individual labor trades or predefined crew bundles and estimate total work hours required for scheduled tasks.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLaborRowIdx(null);
|
||||
setLaborModalOpen(true);
|
||||
}}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-medium text-xs h-9 shadow-xs"
|
||||
>
|
||||
<Layers className="mr-1.5 h-4 w-4" /> Browse Trades & Crew Bundles
|
||||
</Button>
|
||||
<Button onClick={addLaborAllocation} variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50 font-medium text-xs h-9">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Add Row
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={addLaborAllocation} className="bg-emerald-600 hover:bg-emerald-700 text-white font-medium">
|
||||
<Plus className="mr-2 h-4 w-4" /> Assign Labor Record
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
{/* Project Team Pool Selector */}
|
||||
@@ -933,41 +1088,83 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-200/60 flex flex-col gap-1">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 flex items-center gap-1">
|
||||
2. Labor Trade Allocations
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-500">Allocate standard labor trades and estimate total work hours required for scheduled tasks.</p>
|
||||
<div className="pt-4 border-t border-slate-200/60 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 flex items-center gap-1">
|
||||
2. Labor Trade Allocations
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-500">Allocate standard labor trades or assemble pre-built job crew packages for scheduled tasks.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLaborRowIdx(null);
|
||||
setLaborModalOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-indigo-200 text-indigo-700 hover:bg-indigo-50 text-xs font-semibold h-8"
|
||||
>
|
||||
<Layers className="mr-1.5 h-3.5 w-3.5" /> Select from Catalog / Bundles
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localLabor.length === 0 ? (
|
||||
<div className="py-12 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/50">
|
||||
<div className="py-12 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/50 space-y-3">
|
||||
<Users className="mx-auto h-8 w-8 text-slate-400" />
|
||||
<p className="text-xs italic text-slate-500 mt-2">No manpower assigned yet. Click "Assign Labor Record" to allocate labor to tasks.</p>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700">No manpower allocations added yet</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Browse single labor trades or allocate predefined crew packages for your tasks.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 pt-1">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLaborRowIdx(null);
|
||||
setLaborModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-xs"
|
||||
>
|
||||
<Layers className="mr-1.5 h-3.5 w-3.5" /> Browse Catalog & Bundles
|
||||
</Button>
|
||||
<Button
|
||||
onClick={addLaborAllocation}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add Blank Row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm bg-white">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50/60">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-slate-700">Target Task</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Labor Record / Category</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 w-36">Hourly Rate</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 w-32">Estimated Hours</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-right w-36">Total Labor Cost</TableHead>
|
||||
<TableHead className="text-right w-16"></TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 min-w-[220px]">Labor / Crew Allocation</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 w-28">Type</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 w-36">Unit Rate</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 w-36">Estimated Scope</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-right w-36">Total Cost</TableHead>
|
||||
<TableHead className="text-right w-14"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{localLabor.map((item, idx) => {
|
||||
const rateObj = labors.find(r => r.ulid === item.labor_ulid);
|
||||
const rate = rateObj ? Number(rateObj.hourly_rate) : 0;
|
||||
const total = Number(item.estimated_hours || 0) * rate;
|
||||
const isBundle = item.type === 'bundle';
|
||||
const rateObj = !isBundle ? labors.find(r => r.ulid === item.labor_ulid) : null;
|
||||
const unitRate = isBundle
|
||||
? Number(item.unit_rate || 0)
|
||||
: (rateObj ? Number(rateObj.hourly_rate) : Number(item.unit_rate || 0));
|
||||
const qty = Number(item.quantity || item.estimated_hours || 0);
|
||||
const subtotal = qty * unitRate;
|
||||
|
||||
return (
|
||||
<TableRow key={idx} className="hover:bg-slate-50/20 transition-colors">
|
||||
<TableCell className="w-56 max-w-[220px]">
|
||||
<Select
|
||||
value={item.task_ulid}
|
||||
value={item.task_ulid || 'general'}
|
||||
onValueChange={val => updateLaborAllocation(idx, 'task_ulid', val || '')}
|
||||
items={[
|
||||
{ value: 'general', label: 'General Project / Unassigned' },
|
||||
@@ -986,38 +1183,87 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start text-xs border-slate-200 font-normal hover:bg-slate-50 text-left"
|
||||
onClick={() => {
|
||||
setActiveLaborRowIdx(idx);
|
||||
setLaborModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{rateObj ? (
|
||||
<span className="truncate">
|
||||
<span className="font-semibold text-slate-800">{rateObj.name}</span>
|
||||
<span className="text-slate-500 ml-1">({rateObj.category})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-450">Select Labor...</span>
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-slate-650">
|
||||
{rateObj ? `${formatCurrency(rateObj.hourly_rate)} / hr` : '—'}
|
||||
{isBundle ? (
|
||||
<div className="space-y-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveLaborRowIdx(idx);
|
||||
setLaborModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 font-semibold text-slate-800 text-xs hover:text-indigo-600 transition-colors text-left group"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 text-indigo-600 shrink-0 group-hover:scale-110 transition-transform" />
|
||||
<span className="underline-offset-2 group-hover:underline">{item.name}</span>
|
||||
</button>
|
||||
{item.bundle_items && item.bundle_items.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.bundle_items.map((bTrade: any, bIdx: number) => (
|
||||
<span key={bIdx} className="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded border border-slate-200/50">
|
||||
{bTrade.labor_name} ({formatCurrency(bTrade.rate_per_sqm || 0)}/m²)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start text-xs border-slate-200 font-normal hover:bg-slate-50 text-left"
|
||||
onClick={() => {
|
||||
setActiveLaborRowIdx(idx);
|
||||
setLaborModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{rateObj ? (
|
||||
<span className="truncate flex items-center gap-1.5">
|
||||
<HardHat className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
|
||||
<span className="font-semibold text-slate-800">{rateObj.name}</span>
|
||||
<span className="text-slate-400 text-[10px]">({rateObj.category})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-450">Select Labor / Trade...</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.estimated_hours}
|
||||
onChange={e => updateLaborAllocation(idx, 'estimated_hours', e.target.value)}
|
||||
className="h-8 text-xs border-slate-200"
|
||||
/>
|
||||
{isBundle ? (
|
||||
<Badge className="bg-indigo-50 text-indigo-700 border-indigo-100 hover:bg-indigo-50 font-semibold text-[10px] uppercase">
|
||||
Crew Bundle
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-100 hover:bg-emerald-50 font-semibold text-[10px] uppercase">
|
||||
Single Trade
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-slate-700 font-medium">
|
||||
{isBundle ? (
|
||||
<span>{formatCurrency(unitRate)} / m²</span>
|
||||
) : (
|
||||
<span>{rateObj ? `${formatCurrency(unitRate)} / hr` : '—'}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity || item.estimated_hours || ''}
|
||||
onChange={e => updateLaborAllocation(idx, 'quantity', e.target.value)}
|
||||
className="h-8 text-xs border-slate-200 font-mono font-bold pr-9"
|
||||
/>
|
||||
<span className="absolute right-2 text-[11px] font-semibold text-slate-400 select-none pointer-events-none">
|
||||
{isBundle ? 'm²' : 'hrs'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono font-bold text-slate-900 text-right text-xs">
|
||||
{formatCurrency(subtotal)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono font-medium text-slate-700 text-right">{formatCurrency(total)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => removeLaborAllocation(idx)} className="h-8 w-8 text-rose-500 hover:text-rose-700 hover:bg-rose-50">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -1026,9 +1272,9 @@ export default function Wizard({ project, step: currentStep, employees, projects
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
<TableRow className="bg-slate-50/40 hover:bg-slate-50/40">
|
||||
<TableCell colSpan={4} className="font-semibold text-slate-800">Total Manpower Cost Estimate</TableCell>
|
||||
<TableCell className="font-mono font-bold text-slate-900 text-right">{formatCurrency(calculatedLaborCost)}</TableCell>
|
||||
<TableRow className="bg-slate-50/60 hover:bg-slate-50/60">
|
||||
<TableCell colSpan={5} className="font-bold text-slate-800 text-xs">Total Manpower Cost Estimate</TableCell>
|
||||
<TableCell className="font-mono font-bold text-slate-900 text-right text-sm">{formatCurrency(calculatedLaborCost)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
|
||||
@@ -15,10 +15,17 @@ class ProjectProgressController extends Controller
|
||||
$projectUlid = $request->query('project');
|
||||
$project = $projectUlid ? \Modules\ProjectManagement\Models\Project::where('ulid', $projectUlid)->firstOrFail() : null;
|
||||
|
||||
$evmData = null;
|
||||
if ($project) {
|
||||
$progressService = new \Modules\ProjectProgress\Services\ProjectProgressService();
|
||||
$evmData = $progressService->getEvmAnalysisData($project);
|
||||
}
|
||||
|
||||
return \Inertia\Inertia::render('ProjectManagement::Projects/Modules/Progress', [
|
||||
'project' => $project,
|
||||
'projects' => \Modules\ProjectManagement\Models\Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']),
|
||||
'currentTab' => 'progress',
|
||||
'evmData' => $evmData,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,41 @@
|
||||
namespace Modules\ProjectProgress\Services;
|
||||
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\TaskManagement\Models\Task;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
|
||||
class ProjectProgressService
|
||||
{
|
||||
/**
|
||||
* Calculate project progress dynamically using actual material consumed vs BOQ planned quantity.
|
||||
* Formula: sum((Actual Material Consumed / Planned BOQ) * Task Weight)
|
||||
* Calculate project progress dynamically using milestones or actual material/task completions.
|
||||
*/
|
||||
public function calculateProjectProgress(Project $project): float
|
||||
{
|
||||
$tasks = Task::where('project_id', $project->id)
|
||||
->with(['materials'])
|
||||
->get();
|
||||
// 1. Check if project has milestones with weights
|
||||
$milestones = $project->milestones()->with('tasks')->get();
|
||||
if ($milestones->isNotEmpty()) {
|
||||
$totalMilestoneWeight = (float) $milestones->sum('weight_percentage');
|
||||
if ($totalMilestoneWeight > 0) {
|
||||
$totalProgress = 0.0;
|
||||
foreach ($milestones as $milestone) {
|
||||
$tasks = $milestone->tasks;
|
||||
$milestoneProgress = 0.0;
|
||||
if ($tasks->isNotEmpty()) {
|
||||
$milestoneProgress = $tasks->avg('completion_percentage') ?? 0;
|
||||
} elseif ($milestone->actual_date !== null) {
|
||||
$milestoneProgress = 100;
|
||||
}
|
||||
$totalProgress += ($milestoneProgress * (float) $milestone->weight_percentage);
|
||||
}
|
||||
return round($totalProgress / $totalMilestoneWeight, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Direct task-based progress
|
||||
$query = Task::where('project_id', $project->id);
|
||||
if (method_exists(Task::class, 'materials')) {
|
||||
$query->with(['materials']);
|
||||
}
|
||||
$tasks = $query->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
return 0.0;
|
||||
@@ -28,19 +50,372 @@ class ProjectProgressService
|
||||
$taskWeight = $task->weight ?? 1.0;
|
||||
$totalWeight += $taskWeight;
|
||||
|
||||
$plannedBoq = $task->materials->sum('planned_quantity');
|
||||
$consumedQty = $task->materials->sum('consumed_quantity');
|
||||
$plannedBoq = $task->relationLoaded('materials') ? $task->materials->sum('planned_quantity') : 0;
|
||||
$consumedQty = $task->relationLoaded('materials') ? $task->materials->sum('consumed_quantity') : 0;
|
||||
|
||||
if ($plannedBoq > 0) {
|
||||
$materialProgress = min(1.0, $consumedQty / $plannedBoq);
|
||||
$totalWeightedProgress += ($materialProgress * 100) * $taskWeight;
|
||||
} else {
|
||||
// Fallback to task completion percentage if no BOQ material planned
|
||||
$taskProgress = $task->status === 'completed' ? 100 : ($task->progress_percentage ?? 0);
|
||||
$taskProgress = $task->status === 'completed' || $task->status->value === 'completed' ? 100 : ($task->completion_percentage ?? $task->progress_percentage ?? 0);
|
||||
$totalWeightedProgress += $taskProgress * $taskWeight;
|
||||
}
|
||||
}
|
||||
|
||||
return $totalWeight > 0 ? round($totalWeightedProgress / $totalWeight, 2) : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Earned Value Management (EVM) S-Curve timeline data.
|
||||
* Computes Planned Value (PV), Earned Value (EV), Schedule Variance (SV), and Schedule Performance Index (SPI).
|
||||
* Incorporates real-time accomplishment dates from tasks and milestones.
|
||||
*/
|
||||
public function getEvmAnalysisData(Project $project): array
|
||||
{
|
||||
$milestones = $project->milestones()->with('tasks')->get();
|
||||
$tasks = Task::where('project_id', $project->id)->get();
|
||||
$now = now()->startOfDay();
|
||||
$currentEv = $this->calculateProjectProgress($project);
|
||||
|
||||
// Collect all potential start and end dates from project, tasks, and milestones
|
||||
$allStartDates = collect();
|
||||
$allEndDates = collect();
|
||||
|
||||
if ($project->start_date) {
|
||||
$allStartDates->push(\Carbon\Carbon::parse($project->start_date)->startOfDay());
|
||||
}
|
||||
if ($project->target_end_date) {
|
||||
$allEndDates->push(\Carbon\Carbon::parse($project->target_end_date)->startOfDay());
|
||||
}
|
||||
if ($project->end_date) {
|
||||
$allEndDates->push(\Carbon\Carbon::parse($project->end_date)->startOfDay());
|
||||
}
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
if ($task->actual_start_date) $allStartDates->push(\Carbon\Carbon::parse($task->actual_start_date)->startOfDay());
|
||||
if ($task->start_date) $allStartDates->push(\Carbon\Carbon::parse($task->start_date)->startOfDay());
|
||||
if ($task->actual_end_date) $allEndDates->push(\Carbon\Carbon::parse($task->actual_end_date)->startOfDay());
|
||||
if ($task->end_date) $allEndDates->push(\Carbon\Carbon::parse($task->end_date)->startOfDay());
|
||||
if ($task->created_at) $allStartDates->push($task->created_at->copy()->startOfDay());
|
||||
if ($task->status === 'completed' && $task->updated_at) $allEndDates->push($task->updated_at->copy()->startOfDay());
|
||||
}
|
||||
|
||||
foreach ($milestones as $milestone) {
|
||||
if ($milestone->planned_date) $allEndDates->push(\Carbon\Carbon::parse($milestone->planned_date)->startOfDay());
|
||||
if ($milestone->actual_date) {
|
||||
$allEndDates->push(\Carbon\Carbon::parse($milestone->actual_date)->startOfDay());
|
||||
$allStartDates->push(\Carbon\Carbon::parse($milestone->actual_date)->startOfDay());
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective start date: adapt if work started earlier than project planned start
|
||||
$plannedStartDate = $project->start_date ? \Carbon\Carbon::parse($project->start_date)->startOfDay() : ($allStartDates->isNotEmpty() ? $allStartDates->min() : $now->copy()->subMonths(1));
|
||||
$startDate = $allStartDates->isNotEmpty() ? $allStartDates->min() : $plannedStartDate;
|
||||
|
||||
// Target baseline end date
|
||||
$targetEndDate = $project->target_end_date ? \Carbon\Carbon::parse($project->target_end_date)->startOfDay() : ($project->end_date ? \Carbon\Carbon::parse($project->end_date)->startOfDay() : ($allEndDates->isNotEmpty() ? $allEndDates->max() : now()->addMonths(2)->startOfDay()));
|
||||
|
||||
if ($startDate->gte($targetEndDate)) {
|
||||
$targetEndDate = (clone $startDate)->addDays(30);
|
||||
}
|
||||
|
||||
// Determine actual completion date if finished
|
||||
$latestActualCompletionDate = $allEndDates->filter(fn($d) => $d->lte($now))->max() ?? $now;
|
||||
|
||||
$hasMilestones = $milestones->isNotEmpty() && $milestones->sum('weight_percentage') > 0;
|
||||
$totalMilestoneWeight = $hasMilestones ? (float) $milestones->sum('weight_percentage') : 0;
|
||||
|
||||
$totalTaskWeight = $tasks->sum(fn($t) => $t->weight ?? 1.0);
|
||||
if ($totalTaskWeight <= 0) {
|
||||
$totalTaskWeight = max(1, $tasks->count());
|
||||
}
|
||||
|
||||
// Build key dates list to ensure start, target end, now, and regular intervals are present
|
||||
$keyDates = collect([$startDate, $targetEndDate]);
|
||||
$keyDates->push($now);
|
||||
if ($plannedStartDate->between($startDate, $targetEndDate)) {
|
||||
$keyDates->push($plannedStartDate);
|
||||
}
|
||||
if ($currentEv >= 100 && $latestActualCompletionDate->between($startDate, $targetEndDate)) {
|
||||
$keyDates->push($latestActualCompletionDate);
|
||||
}
|
||||
|
||||
// Add 12 evenly distributed sample intervals
|
||||
$totalDays = max(1, $startDate->diffInDays($targetEndDate));
|
||||
$stepDays = max(1, (int)ceil($totalDays / 12));
|
||||
|
||||
$cursor = clone $startDate;
|
||||
while ($cursor->lte($targetEndDate)) {
|
||||
$keyDates->push($cursor->copy());
|
||||
$cursor->addDays($stepDays);
|
||||
}
|
||||
|
||||
// Unique sorted date collection
|
||||
$sortedDates = $keyDates->map(fn($d) => $d->format('Y-m-d'))->unique()->sort()->values();
|
||||
|
||||
$timeSeries = [];
|
||||
foreach ($sortedDates as $dateStr) {
|
||||
$currentDate = \Carbon\Carbon::parse($dateStr)->startOfDay();
|
||||
|
||||
// 1. Calculate Baseline Planned Value (PV) up to currentDate
|
||||
$plannedWeightedSum = 0.0;
|
||||
if ($hasMilestones) {
|
||||
foreach ($milestones as $milestone) {
|
||||
$mWeight = (float) $milestone->weight_percentage;
|
||||
$mTasks = $milestone->tasks;
|
||||
if ($mTasks->isNotEmpty()) {
|
||||
$mPlannedSum = 0.0;
|
||||
foreach ($mTasks as $task) {
|
||||
$tStart = $task->start_date ? \Carbon\Carbon::parse($task->start_date)->startOfDay() : $plannedStartDate;
|
||||
$tEnd = $task->end_date ? \Carbon\Carbon::parse($task->end_date)->startOfDay() : ($milestone->planned_date ? \Carbon\Carbon::parse($milestone->planned_date)->startOfDay() : $targetEndDate);
|
||||
|
||||
if ($currentDate->lt($tStart)) {
|
||||
$pct = 0;
|
||||
} elseif ($currentDate->gte($tEnd)) {
|
||||
$pct = 100;
|
||||
} else {
|
||||
$taskDuration = max(1, $tStart->diffInDays($tEnd));
|
||||
$elapsed = $tStart->diffInDays($currentDate);
|
||||
$pct = min(100, max(0, ($elapsed / $taskDuration) * 100));
|
||||
}
|
||||
$mPlannedSum += $pct;
|
||||
}
|
||||
$plannedWeightedSum += (($mPlannedSum / $mTasks->count()) * $mWeight);
|
||||
} else {
|
||||
$mPlannedDate = $milestone->planned_date ? \Carbon\Carbon::parse($milestone->planned_date)->startOfDay() : $targetEndDate;
|
||||
$pct = $currentDate->gte($mPlannedDate) ? 100 : 0;
|
||||
$plannedWeightedSum += ($pct * $mWeight);
|
||||
}
|
||||
}
|
||||
$pv = round($plannedWeightedSum / $totalMilestoneWeight, 2);
|
||||
} else {
|
||||
foreach ($tasks as $task) {
|
||||
$tWeight = $task->weight ?? 1.0;
|
||||
$tStart = $task->start_date ? \Carbon\Carbon::parse($task->start_date)->startOfDay() : $plannedStartDate;
|
||||
$tEnd = $task->end_date ? \Carbon\Carbon::parse($task->end_date)->startOfDay() : $targetEndDate;
|
||||
|
||||
if ($currentDate->lt($tStart)) {
|
||||
$taskPlannedPct = 0;
|
||||
} elseif ($currentDate->gte($tEnd)) {
|
||||
$taskPlannedPct = 100;
|
||||
} else {
|
||||
$taskDuration = max(1, $tStart->diffInDays($tEnd));
|
||||
$elapsed = $tStart->diffInDays($currentDate);
|
||||
$taskPlannedPct = min(100, max(0, ($elapsed / $taskDuration) * 100));
|
||||
}
|
||||
$plannedWeightedSum += ($taskPlannedPct * $tWeight);
|
||||
}
|
||||
$pv = round($plannedWeightedSum / $totalTaskWeight, 2);
|
||||
}
|
||||
|
||||
// 2. Calculate Real-Time Earned Value (EV) accomplishments up to currentDate
|
||||
$ev = null;
|
||||
if ($currentEv >= 100) {
|
||||
// If project is 100% completed early:
|
||||
if ($currentDate->lt($startDate)) {
|
||||
$ev = 0.0;
|
||||
} elseif ($currentDate->gte($latestActualCompletionDate)) {
|
||||
$ev = 100.0;
|
||||
} else {
|
||||
// Prorated accomplishment between start and completion date
|
||||
$progressDuration = max(1, $startDate->diffInDays($latestActualCompletionDate));
|
||||
$elapsedProgress = $startDate->diffInDays($currentDate);
|
||||
$ev = round(min(100, max(0, ($elapsedProgress / $progressDuration) * 100)), 2);
|
||||
}
|
||||
} elseif ($currentDate->lte($now)) {
|
||||
if ($currentDate->equalTo($now)) {
|
||||
$ev = $currentEv;
|
||||
} elseif ($currentDate->equalTo($startDate) && $currentEv <= 0) {
|
||||
$ev = 0.0;
|
||||
} else {
|
||||
if ($hasMilestones) {
|
||||
$earnedWeightedSum = 0.0;
|
||||
foreach ($milestones as $milestone) {
|
||||
$mWeight = (float) $milestone->weight_percentage;
|
||||
$mTasks = $milestone->tasks;
|
||||
if ($mTasks->isNotEmpty()) {
|
||||
$mTaskAccomplishmentSum = 0.0;
|
||||
foreach ($mTasks as $task) {
|
||||
$taskAccomplishment = $this->calculateTaskAccomplishmentAtDate($task, $currentDate, $startDate, $targetEndDate);
|
||||
$mTaskAccomplishmentSum += $taskAccomplishment;
|
||||
}
|
||||
$earnedWeightedSum += (($mTaskAccomplishmentSum / $mTasks->count()) * $mWeight);
|
||||
} else {
|
||||
$mActualDate = $milestone->actual_date ? \Carbon\Carbon::parse($milestone->actual_date)->startOfDay() : null;
|
||||
$mPct = ($mActualDate && $currentDate->gte($mActualDate)) ? 100 : 0;
|
||||
$earnedWeightedSum += ($mPct * $mWeight);
|
||||
}
|
||||
}
|
||||
$ev = round($earnedWeightedSum / $totalMilestoneWeight, 2);
|
||||
} else {
|
||||
$earnedWeightedSum = 0.0;
|
||||
foreach ($tasks as $task) {
|
||||
$tWeight = $task->weight ?? 1.0;
|
||||
$taskAccomplishment = $this->calculateTaskAccomplishmentAtDate($task, $currentDate, $startDate, $targetEndDate);
|
||||
$earnedWeightedSum += ($taskAccomplishment * $tWeight);
|
||||
}
|
||||
$ev = round($earnedWeightedSum / $totalTaskWeight, 2);
|
||||
}
|
||||
// Cap historical EV by currentEv
|
||||
$ev = min($currentEv, $ev);
|
||||
}
|
||||
}
|
||||
|
||||
$sv = $ev !== null ? round($ev - $pv, 2) : null;
|
||||
$spi = ($ev !== null && $pv > 0) ? round($ev / $pv, 2) : ($ev !== null ? 1.0 : null);
|
||||
|
||||
$timeSeries[] = [
|
||||
'date' => $currentDate->format('M d'),
|
||||
'full_date' => $dateStr,
|
||||
'is_today' => $currentDate->equalTo($now),
|
||||
'planned_pv' => $pv,
|
||||
'actual_ev' => $ev,
|
||||
'schedule_variance' => $sv,
|
||||
'spi' => $spi,
|
||||
];
|
||||
}
|
||||
|
||||
// Current real-time overall EV vs PV as of today
|
||||
$todayPoint = collect($timeSeries)->firstWhere('is_today', true);
|
||||
$currentPv = $todayPoint ? $todayPoint['planned_pv'] : (end($timeSeries)['planned_pv'] ?? 0);
|
||||
$currentSv = round($currentEv - $currentPv, 2);
|
||||
$currentSpi = $currentPv > 0 ? round($currentEv / $currentPv, 2) : ($currentEv > 0 ? 1.0 : 1.0);
|
||||
|
||||
// Dynamic Projected Finish Date based on SPI and Actual Early Completion
|
||||
if ($currentEv >= 100) {
|
||||
$projectedEndDate = $latestActualCompletionDate;
|
||||
$daysVariance = $targetEndDate->diffInDays($projectedEndDate, false);
|
||||
$status = $daysVariance < 0 ? 'Completed Ahead of Schedule' : ($daysVariance === 0 ? 'Completed On Schedule' : 'Completed with Delay');
|
||||
} elseif ($currentSpi > 0 && $currentSpi < 1.0) {
|
||||
$daysRemaining = max(0, $now->diffInDays($targetEndDate, false));
|
||||
$adjustedDays = (int)ceil($daysRemaining / $currentSpi);
|
||||
$projectedEndDate = $now->copy()->addDays($adjustedDays);
|
||||
$daysVariance = $targetEndDate->diffInDays($projectedEndDate, false);
|
||||
$status = $currentSv > -5 ? 'On Track' : 'Delayed';
|
||||
} else {
|
||||
// Ahead of schedule or on track
|
||||
$daysRemaining = max(0, $now->diffInDays($targetEndDate, false));
|
||||
$adjustedDays = $currentSpi > 1.0 ? (int)ceil($daysRemaining / $currentSpi) : $daysRemaining;
|
||||
$projectedEndDate = $now->copy()->addDays($adjustedDays);
|
||||
$daysVariance = $targetEndDate->diffInDays($projectedEndDate, false);
|
||||
$status = 'Ahead of Schedule';
|
||||
}
|
||||
|
||||
// Build Milestone Turnover Coordination dataset
|
||||
$milestonesData = $milestones->map(function ($milestone) use ($now) {
|
||||
$mTasks = $milestone->tasks;
|
||||
$tasksCount = $mTasks->count();
|
||||
$completedTasksCount = $mTasks->filter(function ($t) {
|
||||
return $t->status === 'completed' || (is_object($t->status) && $t->status->value === 'completed') || (float)$t->completion_percentage >= 100;
|
||||
})->count();
|
||||
|
||||
$avgTaskProgress = $tasksCount > 0 ? round($mTasks->avg('completion_percentage') ?? 0, 1) : ($milestone->actual_date ? 100.0 : 0.0);
|
||||
$isTurnovered = $milestone->actual_date !== null || ($tasksCount > 0 && $completedTasksCount === $tasksCount);
|
||||
|
||||
// Determine Turnover Status
|
||||
$plannedDate = $milestone->planned_date ? \Carbon\Carbon::parse($milestone->planned_date)->startOfDay() : null;
|
||||
$actualDate = $milestone->actual_date ? \Carbon\Carbon::parse($milestone->actual_date)->startOfDay() : null;
|
||||
|
||||
if ($actualDate) {
|
||||
if ($plannedDate && $actualDate->lt($plannedDate)) {
|
||||
$turnoverStatus = 'Turnovered Ahead';
|
||||
$turnoverStatusColor = 'emerald';
|
||||
} elseif ($plannedDate && $actualDate->equalTo($plannedDate)) {
|
||||
$turnoverStatus = 'Turnovered On Time';
|
||||
$turnoverStatusColor = 'emerald';
|
||||
} else {
|
||||
$turnoverStatus = 'Turnovered with Delay';
|
||||
$turnoverStatusColor = 'amber';
|
||||
}
|
||||
} elseif ($avgTaskProgress >= 100) {
|
||||
$turnoverStatus = 'Ready for Turnover';
|
||||
$turnoverStatusColor = 'blue';
|
||||
} elseif ($plannedDate && $plannedDate->lt($now)) {
|
||||
$turnoverStatus = 'Overdue Turnover';
|
||||
$turnoverStatusColor = 'rose';
|
||||
} elseif ($avgTaskProgress > 0) {
|
||||
$turnoverStatus = 'In Progress';
|
||||
$turnoverStatusColor = 'indigo';
|
||||
} else {
|
||||
$turnoverStatus = 'Upcoming';
|
||||
$turnoverStatusColor = 'slate';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $milestone->id,
|
||||
'name' => $milestone->name,
|
||||
'description' => $milestone->description,
|
||||
'weight_percentage' => (float)$milestone->weight_percentage,
|
||||
'planned_date' => $plannedDate ? $plannedDate->format('M d, Y') : null,
|
||||
'actual_date' => $actualDate ? $actualDate->format('M d, Y') : null,
|
||||
'tasks_count' => $tasksCount,
|
||||
'completed_tasks_count' => $completedTasksCount,
|
||||
'progress_percentage' => $avgTaskProgress,
|
||||
'is_turnovered' => $isTurnovered,
|
||||
'turnover_status' => $turnoverStatus,
|
||||
'turnover_status_color' => $turnoverStatusColor,
|
||||
'days_variance' => $plannedDate && $actualDate ? $plannedDate->diffInDays($actualDate, false) : ($plannedDate && $plannedDate->lt($now) && !$isTurnovered ? $plannedDate->diffInDays($now, false) : 0),
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return [
|
||||
'timeSeries' => $timeSeries,
|
||||
'summary' => [
|
||||
'planned_pv' => $currentPv,
|
||||
'actual_ev' => $currentEv,
|
||||
'schedule_variance' => $currentSv,
|
||||
'spi' => $currentSpi,
|
||||
'status' => $status,
|
||||
'target_end_date' => $targetEndDate->format('M d, Y'),
|
||||
'projected_end_date' => $projectedEndDate->format('M d, Y'),
|
||||
'days_variance' => $daysVariance,
|
||||
'is_completed' => $currentEv >= 100,
|
||||
],
|
||||
'milestones' => $milestonesData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine task completion % achieved as of a given historical date.
|
||||
*/
|
||||
protected function calculateTaskAccomplishmentAtDate(Task $task, \Carbon\Carbon $currentDate, \Carbon\Carbon $projectStart, \Carbon\Carbon $projectTargetEnd): float
|
||||
{
|
||||
$actualEndDate = $task->actual_end_date ? \Carbon\Carbon::parse($task->actual_end_date) : null;
|
||||
$actualStartDate = $task->actual_start_date ? \Carbon\Carbon::parse($task->actual_start_date) : null;
|
||||
$plannedStartDate = $task->start_date ? \Carbon\Carbon::parse($task->start_date) : $projectStart;
|
||||
$plannedEndDate = $task->end_date ? \Carbon\Carbon::parse($task->end_date) : $projectTargetEnd;
|
||||
|
||||
// 1. If task has a verified actual completion date
|
||||
if ($actualEndDate && $currentDate->gte($actualEndDate)) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
// 2. If task status is completed and updatedAt is before/on currentDate
|
||||
$isCompletedStatus = $task->status === 'completed' || (is_object($task->status) && $task->status->value === 'completed');
|
||||
if ($isCompletedStatus && $task->updated_at && $currentDate->gte($task->updated_at->copy()->startOfDay())) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
// 3. If work started on or before currentDate
|
||||
$effectiveStart = $actualStartDate ?? $plannedStartDate;
|
||||
if ($currentDate->lt($effectiveStart)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$effectiveEnd = $actualEndDate ?? $plannedEndDate;
|
||||
$currentCompletion = (float)($isCompletedStatus ? 100 : ($task->completion_percentage ?? $task->progress_percentage ?? 0));
|
||||
|
||||
if ($currentCompletion <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($currentDate->gte(now()->startOfDay())) {
|
||||
return $currentCompletion;
|
||||
}
|
||||
|
||||
$durationDays = max(1, $effectiveStart->diffInDays($effectiveEnd));
|
||||
$elapsedDays = $effectiveStart->diffInDays($currentDate);
|
||||
|
||||
return min($currentCompletion, round(($elapsedDays / $durationDays) * $currentCompletion, 2));
|
||||
}
|
||||
}
|
||||
|
||||
92
Modules/ProjectProgress/tests/Feature/EvmCalculationTest.php
Normal file
92
Modules/ProjectProgress/tests/Feature/EvmCalculationTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectProgress\Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
use Modules\ProjectProgress\Services\ProjectProgressService;
|
||||
|
||||
class EvmCalculationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_evm_s_curve_data_generation()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
'end_date' => now()->subDays(2)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 50,
|
||||
'start_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(15)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertIsArray($evmData);
|
||||
$this->assertArrayHasKey('timeSeries', $evmData);
|
||||
$this->assertArrayHasKey('summary', $evmData);
|
||||
|
||||
$summary = $evmData['summary'];
|
||||
$this->assertGreaterThan(0, $summary['actual_ev']);
|
||||
$this->assertNotNull($summary['spi']);
|
||||
$this->assertNotNull($summary['schedule_variance']);
|
||||
}
|
||||
|
||||
public function test_evm_with_milestones_and_accomplishment_dates()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$m1 = $project->milestones()->create([
|
||||
'name' => 'Substructure',
|
||||
'weight_percentage' => 40,
|
||||
'planned_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
'actual_date' => now()->subDays(12)->format('Y-m-d'),
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$m2 = $project->milestones()->create([
|
||||
'name' => 'Superstructure',
|
||||
'weight_percentage' => 60,
|
||||
'planned_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
'actual_date' => null,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'milestone_id' => $m2->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 50,
|
||||
'start_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'actual_start_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertIsArray($evmData);
|
||||
$this->assertNotEmpty($evmData['timeSeries']);
|
||||
// Milestone 1 (40%) completed + Milestone 2 (50% of 60% = 30%) -> Total 70%
|
||||
$this->assertEquals(70.0, $evmData['summary']['actual_ev']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectProgress\Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
use Modules\ProjectProgress\Services\ProjectProgressService;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProgressMonitoringFeatureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$role = Role::firstOrCreate(['name' => 'Super Admin']);
|
||||
Permission::firstOrCreate(['name' => 'projects.access']);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'status' => 'active',
|
||||
'user_type' => 'admin',
|
||||
]);
|
||||
$this->user->assignRole($role);
|
||||
}
|
||||
|
||||
public function test_progress_monitoring_page_renders_with_inertia_props_when_project_selected(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(20)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(20)->format('Y-m-d'),
|
||||
'actual_end_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('projects.progress.index', ['project' => $project->ulid]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('ProjectManagement::Projects/Modules/Progress', false)
|
||||
->has('project')
|
||||
->has('projects')
|
||||
->where('currentTab', 'progress')
|
||||
->has('evmData', fn (Assert $evm) => $evm
|
||||
->has('timeSeries')
|
||||
->has('milestones')
|
||||
->has('summary', fn (Assert $summary) => $summary
|
||||
->has('planned_pv')
|
||||
->has('actual_ev')
|
||||
->has('schedule_variance')
|
||||
->has('spi')
|
||||
->has('status')
|
||||
->has('target_end_date')
|
||||
->has('projected_end_date')
|
||||
->has('days_variance')
|
||||
->has('is_completed')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_progress_monitoring_page_without_project_param_renders_project_selection_grid(): void
|
||||
{
|
||||
Project::factory()->count(3)->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('projects.progress.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('ProjectManagement::Projects/Modules/Progress', false)
|
||||
->where('project', null)
|
||||
->where('evmData', null)
|
||||
->has('projects', 3)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_project_overview_page_provides_evm_data_prop(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(15)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(15)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('projects.show', $project->ulid));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('ProjectManagement::Projects/Overview', false)
|
||||
->has('evmData')
|
||||
->has('evmData.timeSeries')
|
||||
->has('evmData.summary')
|
||||
->has('evmData.milestones')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_evm_calculation_handles_ahead_of_schedule_status(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Task completed way ahead of schedule
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'actual_end_date' => now()->subDays(20)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$summary = $evmData['summary'];
|
||||
$this->assertEquals(100.0, $summary['actual_ev']);
|
||||
$this->assertGreaterThan(0, $summary['schedule_variance']);
|
||||
$this->assertGreaterThanOrEqual(1.0, $summary['spi']);
|
||||
$this->assertEquals('Completed Ahead of Schedule', $summary['status']);
|
||||
$this->assertTrue($summary['is_completed']);
|
||||
}
|
||||
|
||||
public function test_evm_calculation_handles_delayed_status_and_forecasted_end_date(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Only 10% completed while planned is ~75%
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 10,
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$summary = $evmData['summary'];
|
||||
$this->assertLessThan(0, $summary['schedule_variance']);
|
||||
$this->assertLessThan(1.0, $summary['spi']);
|
||||
$this->assertEquals('Delayed', $summary['status']);
|
||||
$this->assertGreaterThan(0, $summary['days_variance']);
|
||||
}
|
||||
|
||||
public function test_evm_calculation_handles_empty_project_gracefully(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertIsArray($evmData);
|
||||
$this->assertNotEmpty($evmData['timeSeries']);
|
||||
$this->assertEquals(0.0, $evmData['summary']['actual_ev']);
|
||||
}
|
||||
|
||||
public function test_task_and_scheduling_coordinated_with_milestone_turnover(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Milestone 1: Structural Works (Turnovered Ahead)
|
||||
$m1 = \Modules\ProjectManagement\Models\ProjectMilestone::create([
|
||||
'project_id' => $project->id,
|
||||
'name' => 'Foundation & Structural Turnover',
|
||||
'weight_percentage' => 60.0,
|
||||
'planned_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'actual_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'milestone_id' => $m1->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'actual_end_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Milestone 2: Finishing Works (In Progress / Ready for Turnover)
|
||||
$m2 = \Modules\ProjectManagement\Models\ProjectMilestone::create([
|
||||
'project_id' => $project->id,
|
||||
'name' => 'Finishing & Final Turnover',
|
||||
'weight_percentage' => 40.0,
|
||||
'planned_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
'actual_date' => null,
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'milestone_id' => $m2->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 50,
|
||||
'start_date' => now()->subDays(9)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertNotEmpty($evmData['milestones']);
|
||||
$this->assertCount(2, $evmData['milestones']);
|
||||
|
||||
$m1Data = collect($evmData['milestones'])->firstWhere('id', $m1->id);
|
||||
$this->assertEquals('Turnovered Ahead', $m1Data['turnover_status']);
|
||||
$this->assertTrue($m1Data['is_turnovered']);
|
||||
$this->assertEquals(100.0, $m1Data['progress_percentage']);
|
||||
|
||||
$m2Data = collect($evmData['milestones'])->firstWhere('id', $m2->id);
|
||||
$this->assertEquals('In Progress', $m2Data['turnover_status']);
|
||||
$this->assertFalse($m2Data['is_turnovered']);
|
||||
$this->assertEquals(50.0, $m2Data['progress_percentage']);
|
||||
}
|
||||
}
|
||||
@@ -391,8 +391,21 @@ class DashboardController extends Controller
|
||||
$invoicesQuery->whereIn('project_id', $visibleProjectIds);
|
||||
}
|
||||
$totalBilled = (float) $invoicesQuery->sum('subtotal');
|
||||
$totalPaid = (float) $invoicesQuery->sum('paid_amount');
|
||||
$totalRetention = (float) $invoicesQuery->sum('retention_amount');
|
||||
$totalPaid = (float) (clone $invoicesQuery)->where('status', 'paid')->sum('paid_amount');
|
||||
|
||||
$retentionLedgerQuery = \Modules\FinancialManagement\Models\RetentionEntry::query();
|
||||
if ($project) {
|
||||
$retentionLedgerQuery->where('project_id', $project->id);
|
||||
} else {
|
||||
$retentionLedgerQuery->whereIn('project_id', $visibleProjectIds);
|
||||
}
|
||||
$heldDebit = (float) (clone $retentionLedgerQuery)->where('type', 'debit')->sum('amount');
|
||||
$releasedCredit = (float) (clone $retentionLedgerQuery)->where('type', 'credit')->whereIn('status', ['posted', 'paid'])->sum('amount');
|
||||
$totalRetention = max(0, $heldDebit - $releasedCredit);
|
||||
|
||||
if ($totalRetention == 0 && (clone $invoicesQuery)->whereIn('status', ['approved', 'sent', 'payment_sent', 'paid'])->exists()) {
|
||||
$totalRetention = (float) (clone $invoicesQuery)->whereIn('status', ['approved', 'sent', 'payment_sent', 'paid'])->sum('retention_amount');
|
||||
}
|
||||
|
||||
$pendingApprovalsCount = \Modules\ApprovalWorkflow\Models\ApprovalChain::where('status', 'in_review')->count();
|
||||
$projectsAnalyticsQuery = Project::query();
|
||||
|
||||
@@ -86,19 +86,29 @@ class HandleInertiaRequests extends Middleware
|
||||
->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id', 'status'])
|
||||
: [],
|
||||
'unconfirmed_payments' => fn () => $request->user() ? (function () use ($request) {
|
||||
$user = $request->user();
|
||||
$userType = strtolower($user->user_type ?? '');
|
||||
$roles = strtolower($user->getRoleNames()->implode(','));
|
||||
$isExecutive = in_array($userType, ['admin', 'super_admin', 'project_manager'])
|
||||
|| $user->hasAnyRole(['Super Admin', 'admin', 'Admin', 'Project Manager', 'Executive']);
|
||||
|
||||
$visibleProjectIds = Project::query()->pluck('projects.id')->all();
|
||||
|
||||
$pendingInvoices = \Modules\FinancialManagement\Models\FinancialInvoice::with('project:id,name,code')
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->where('status', 'payment_sent')
|
||||
->get(['id', 'ulid', 'invoice_number', 'project_id', 'total_amount', 'status'])
|
||||
->get(['id', 'ulid', 'invoice_number', 'project_id', 'total_amount', 'retention_amount', 'status', 'payment_proof_path', 'payment_proof_name', 'payment_proof_submitted_at'])
|
||||
->map(fn ($inv) => [
|
||||
'id' => 'inv_' . $inv->id,
|
||||
'ulid' => $inv->ulid,
|
||||
'number' => $inv->invoice_number,
|
||||
'project_name' => $inv->project?->name ?? 'Project',
|
||||
'amount' => (float) $inv->total_amount,
|
||||
'retention_amount' => (float) $inv->retention_amount,
|
||||
'has_proof' => !empty($inv->payment_proof_path),
|
||||
'proof_name' => $inv->payment_proof_name,
|
||||
'type' => 'invoice',
|
||||
'confirm_url' => route('finance.confirm-payment', $inv->ulid),
|
||||
'confirm_url' => route('finance.receive-payment', $inv->ulid),
|
||||
'view_url' => route('finance.show', $inv->ulid),
|
||||
]);
|
||||
|
||||
@@ -106,7 +116,7 @@ class HandleInertiaRequests extends Middleware
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->where('type', 'credit')
|
||||
->whereIn('status', ['payment_sent', 'submitted'])
|
||||
->get(['id', 'ulid', 'project_id', 'amount', 'status', 'description'])
|
||||
->get(['id', 'ulid', 'project_id', 'amount', 'status', 'description', 'media_path', 'media_original_name'])
|
||||
->map(fn ($ret) => [
|
||||
'id' => 'ret_' . $ret->id,
|
||||
'ulid' => $ret->ulid,
|
||||
@@ -126,6 +136,72 @@ class HandleInertiaRequests extends Middleware
|
||||
'items' => $merged->values()->all(),
|
||||
];
|
||||
})() : ['count' => 0, 'total_amount' => 0, 'items' => []],
|
||||
'retention_reminders' => fn () => $request->user() ? (function () use ($request) {
|
||||
$user = $request->user();
|
||||
$userType = strtolower($user->user_type ?? '');
|
||||
$roles = strtolower($user->getRoleNames()->implode(','));
|
||||
$isExecutive = in_array($userType, ['admin', 'super_admin', 'project_manager'])
|
||||
|| $user->hasAnyRole(['Super Admin', 'admin', 'Admin', 'Project Manager', 'Executive']);
|
||||
|
||||
$visibleProjectIds = Project::query()->pluck('projects.id')->all();
|
||||
|
||||
if ($isExecutive) {
|
||||
// Remind Executive to check payment received when payment proof is submitted
|
||||
$proofSubmittedInvoices = \Modules\FinancialManagement\Models\FinancialInvoice::with('project:id,name,code')
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->where('status', 'payment_sent')
|
||||
->whereNotNull('payment_proof_submitted_at')
|
||||
->get(['id', 'ulid', 'invoice_number', 'project_id', 'total_amount', 'retention_amount', 'penalty_amount', 'payment_proof_name', 'payment_proof_submitted_at'])
|
||||
->map(fn ($inv) => [
|
||||
'id' => 'inv_proof_' . $inv->id,
|
||||
'ulid' => $inv->ulid,
|
||||
'invoice_number' => $inv->invoice_number,
|
||||
'project_name' => $inv->project?->name ?? 'Project',
|
||||
'amount' => (float) $inv->total_amount,
|
||||
'retention_amount' => (float) $inv->retention_amount + (float) $inv->penalty_amount,
|
||||
'base_retention_amount' => (float) $inv->retention_amount,
|
||||
'penalty_amount' => (float) $inv->penalty_amount,
|
||||
'role_target' => 'executive',
|
||||
'action_type' => 'check_payment_received',
|
||||
'title' => 'Payment Received Check Needed',
|
||||
'message' => "Contractor submitted payment proof for Invoice {$inv->invoice_number}. Please check and confirm receipt.",
|
||||
'view_url' => route('finance.show', $inv->ulid),
|
||||
]);
|
||||
|
||||
return [
|
||||
'count' => $proofSubmittedInvoices->count(),
|
||||
'items' => $proofSubmittedInvoices->values()->all(),
|
||||
];
|
||||
} else {
|
||||
// Remind Contractor Admin to pay 10% and send payment proof for approved invoices
|
||||
$awaitingProofInvoices = \Modules\FinancialManagement\Models\FinancialInvoice::with('project:id,name,code')
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->whereIn('status', ['approved', 'sent'])
|
||||
->whereNull('payment_proof_submitted_at')
|
||||
->get(['id', 'ulid', 'invoice_number', 'project_id', 'total_amount', 'retention_amount', 'penalty_amount', 'retention_rate'])
|
||||
->map(fn ($inv) => [
|
||||
'id' => 'inv_pay_' . $inv->id,
|
||||
'ulid' => $inv->ulid,
|
||||
'invoice_number' => $inv->invoice_number,
|
||||
'project_name' => $inv->project?->name ?? 'Project',
|
||||
'amount' => (float) $inv->total_amount,
|
||||
'retention_amount' => (float) $inv->retention_amount + (float) $inv->penalty_amount,
|
||||
'base_retention_amount' => (float) $inv->retention_amount,
|
||||
'penalty_amount' => (float) $inv->penalty_amount,
|
||||
'retention_rate' => (float) $inv->retention_rate,
|
||||
'role_target' => 'contractor',
|
||||
'action_type' => 'send_payment_proof',
|
||||
'title' => '10% Payment & Proof Reminder',
|
||||
'message' => "Invoice {$inv->invoice_number} is approved. Please remit the payment" . ((float) $inv->penalty_amount > 0 ? " (including penalty)" : "") . " and upload payment proof.",
|
||||
'view_url' => route('finance.show', $inv->ulid),
|
||||
]);
|
||||
|
||||
return [
|
||||
'count' => $awaitingProofInvoices->count(),
|
||||
'items' => $awaitingProofInvoices->values()->all(),
|
||||
];
|
||||
}
|
||||
})() : ['count' => 0, 'items' => []],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,11 @@ export default function ContractorDashboardView({ analytics, activities }: Contr
|
||||
<Card className="bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase font-semibold text-gray-500 dark:text-gray-400">Retention Withheld</p>
|
||||
<h4 className="text-2xl font-black mt-1 text-gray-900 dark:text-white">{formatCurrency(analytics?.financials?.total_retention)}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">Held until final acceptance</p>
|
||||
<p className="text-xs uppercase font-semibold text-gray-500 dark:text-gray-400">Retention Remitted / Withheld</p>
|
||||
<h4 className="text-2xl font-black mt-1 text-rose-600 dark:text-rose-400">-{formatCurrency(analytics?.financials?.total_retention)}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">Withheld until final acceptance</p>
|
||||
</div>
|
||||
<Layers className="w-8 h-8 text-amber-600 dark:text-amber-400" />
|
||||
<Layers className="w-8 h-8 text-rose-600 dark:text-rose-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ export default function ExecutiveDashboardView({ analytics, activities, blockers
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1">
|
||||
<div className="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
|
||||
{formatCurrency(analytics?.financials?.total_retention)}
|
||||
<div className="text-3xl font-black text-emerald-600 dark:text-emerald-400 tracking-tight">
|
||||
+{formatCurrency(analytics?.financials?.total_retention)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Total contract retention held in escrow</p>
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800/80 flex justify-between items-center text-xs text-gray-500">
|
||||
@@ -99,7 +99,7 @@ export default function ExecutiveDashboardView({ analytics, activities, blockers
|
||||
<div className="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
|
||||
{analytics?.approvals?.pending || 0} Invoices
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Awaiting Super Admin / Admin Approval</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Awaiting Executive Approval (Super Admin / Admin / PM)</p>
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800/80 flex justify-between items-center text-xs text-gray-500">
|
||||
<span className="font-medium">Governance Queue</span>
|
||||
<Link href="/approvals" className="text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-1 font-semibold">
|
||||
|
||||
@@ -85,9 +85,9 @@ export default function RoleAnalyticsBanner({ analytics }: RoleAnalyticsProps) {
|
||||
<Card className="bg-gradient-to-br from-emerald-900 to-slate-900 text-white shadow-md border-0">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-200">Total Retention</p>
|
||||
<h4 className="text-2xl font-black mt-1">{formatCurrency(analytics?.financials?.total_retention || 0)}</h4>
|
||||
<p className="text-xs text-emerald-300 mt-1">Withheld in project ledger</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-200">Total Retention Held</p>
|
||||
<h4 className="text-2xl font-black mt-1 text-emerald-300">+{formatCurrency(analytics?.financials?.total_retention || 0)}</h4>
|
||||
<p className="text-xs text-emerald-300 mt-1">Held in escrow ledger</p>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-800/50 rounded-xl text-emerald-300">
|
||||
<Layers className="w-6 h-6" />
|
||||
@@ -214,9 +214,9 @@ export default function RoleAnalyticsBanner({ analytics }: RoleAnalyticsProps) {
|
||||
<Card className="bg-gradient-to-br from-amber-900 to-slate-900 text-white shadow-md border-0">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-200">Retention Amount</p>
|
||||
<h4 className="text-2xl font-black mt-1">{formatCurrency(analytics?.financials?.total_retention || 0)}</h4>
|
||||
<p className="text-xs text-amber-300 mt-1">Held until project completion</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-200">Retention Remitted / Withheld</p>
|
||||
<h4 className="text-2xl font-black mt-1 text-rose-300">-{formatCurrency(analytics?.financials?.total_retention || 0)}</h4>
|
||||
<p className="text-xs text-amber-300 mt-1">Withheld until final project release</p>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-800/50 rounded-xl text-amber-300">
|
||||
<Layers className="w-6 h-6" />
|
||||
|
||||
@@ -29,7 +29,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
"fixed inset-0 isolate z-50 bg-black/60 duration-100 backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -51,7 +51,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-white shadow-2xl p-4 text-sm text-slate-900 border border-slate-200 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,20 +8,16 @@ import { ShieldAlert, Check, AlertCircle, X, Bell, DollarSign, ArrowRight } from
|
||||
import Modal from '@/Components/Modal';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import RetentionStickyToast from '@modules/FinancialManagement/resources/js/Components/RetentionStickyToast';
|
||||
|
||||
export default function Authenticated({
|
||||
header,
|
||||
children,
|
||||
}: PropsWithChildren<{ header?: ReactNode }>) {
|
||||
const { flash, auth } = usePage<PageProps & { unconfirmed_payments?: { count: number; total_amount: number; items: any[] } }>().props;
|
||||
const { flash, auth } = usePage<PageProps>().props;
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [localFlash, setLocalFlash] = useState<{ success?: string; error?: string } | null>(null);
|
||||
const [dismissPill, setDismissPill] = useState(false);
|
||||
|
||||
const unconfirmedPayments = (usePage().props as any).unconfirmed_payments;
|
||||
const pendingCount = unconfirmedPayments?.count || 0;
|
||||
const pendingTotal = unconfirmedPayments?.total_amount || 0;
|
||||
|
||||
const userRoles = ((auth?.roles || []) as string[]).map(r => (typeof r === 'string' ? r : (r as any).name || '').toLowerCase());
|
||||
const userType = (auth?.user?.user_type || '').toLowerCase();
|
||||
@@ -127,46 +123,8 @@ export default function Authenticated({
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Sticky Notification Pill for Unconfirmed Payments */}
|
||||
{isContractorAdmin && pendingCount > 0 && !dismissPill && (
|
||||
<div className="fixed bottom-5 right-5 z-50 animate-bounce-short">
|
||||
<div className="flex items-center gap-3 bg-gradient-to-r from-emerald-900 via-slate-900 to-indigo-950 text-white p-4 rounded-2xl shadow-2xl border border-emerald-500/40 backdrop-blur-md max-w-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||
<DollarSign className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-400/20 px-2 py-0.5 text-xs font-bold text-emerald-300 ring-1 ring-inset ring-emerald-400/30">
|
||||
{pendingCount} Pending Release{pendingCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-emerald-200">{formatCurrency(pendingTotal)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 mt-1 truncate">
|
||||
Executive released funds. Awaiting confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={route('retention.index')}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
aria-label="View Retention Details"
|
||||
title="View Retention Details"
|
||||
className="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold shadow-md h-8 w-8 rounded-xl shrink-0 flex items-center justify-center transition-transform hover:scale-105"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissPill(true)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Persistent Retention & 10% Payment Sticky Toast */}
|
||||
<RetentionStickyToast />
|
||||
</SidebarInset>
|
||||
|
||||
<Modal show={showErrorModal} onClose={handleCloseError} maxWidth="md">
|
||||
|
||||
24
resources/js/types/index.d.ts
vendored
24
resources/js/types/index.d.ts
vendored
@@ -81,6 +81,21 @@ export interface SidebarBadges {
|
||||
pending_approvals: number;
|
||||
}
|
||||
|
||||
export interface RetentionReminderItem {
|
||||
id: string;
|
||||
ulid: string;
|
||||
invoice_number: string;
|
||||
project_name: string;
|
||||
amount: number;
|
||||
retention_amount?: number;
|
||||
retention_rate?: number;
|
||||
role_target: 'contractor' | 'executive';
|
||||
action_type: 'send_payment_proof' | 'check_payment_received';
|
||||
title: string;
|
||||
message: string;
|
||||
view_url: string;
|
||||
}
|
||||
|
||||
export type PageProps<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = T & {
|
||||
@@ -94,4 +109,13 @@ export type PageProps<
|
||||
error?: string;
|
||||
};
|
||||
sidebarBadges?: SidebarBadges;
|
||||
retention_reminders?: {
|
||||
count: number;
|
||||
items: RetentionReminderItem[];
|
||||
};
|
||||
unconfirmed_payments?: {
|
||||
count: number;
|
||||
total_amount: number;
|
||||
items: any[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -167,7 +167,7 @@ class RoleBasedActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function project_manager_cannot_approve_submitted_invoice()
|
||||
public function supervisor_cannot_approve_submitted_invoice()
|
||||
{
|
||||
$invoice = \Modules\FinancialManagement\Models\FinancialInvoice::create([
|
||||
'project_id' => $this->project->id,
|
||||
@@ -181,7 +181,7 @@ class RoleBasedActionTest extends TestCase
|
||||
'invoice_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->projectManager)
|
||||
$response = $this->actingAs($this->supervisor)
|
||||
->patch(route('finance.approve', $invoice->ulid));
|
||||
|
||||
$this->assertDatabaseHas('financial_invoices', [
|
||||
|
||||
Reference in New Issue
Block a user