From 1ca1d286cd6c8aa443f1d78e9e4f8d40a82e2b64 Mon Sep 17 00:00:00 2001 From: Ajjj Date: Fri, 14 Aug 2026 15:29:55 +0800 Subject: [PATCH] Implement revised retention workflow, overdue late penalty calculation, payment proof verification, and dynamic ledger reconciliation --- .../Http/Controllers/FinanceController.php | 217 +++++++- .../app/Models/FinancialInvoice.php | 53 ++ .../app/Services/ProgressBillingService.php | 125 +++++ ...ment_proof_to_financial_invoices_table.php | 33 ++ ...lty_fields_to_financial_invoices_table.php | 33 ++ .../Components/RetentionPaymentProofModal.tsx | 243 +++++++++ .../js/Components/RetentionPenaltyModal.tsx | 290 ++++++++++ .../js/Components/RetentionStickyToast.tsx | 155 ++++++ .../resources/js/Pages/Invoices/Index.tsx | 76 ++- .../resources/js/Pages/Invoices/Show.tsx | 308 ++++++++--- .../resources/js/Pages/Retention/Index.tsx | 189 ++++--- Modules/FinancialManagement/routes/web.php | 4 + .../Feature/RevisedRetentionFlowTest.php | 305 +++++++++++ Modules/Labors/resources/js/Pages/Index.tsx | 65 ++- .../Requisitions/MaterialCatalogModal.tsx | 398 +++++++++----- .../Http/Controllers/ProjectController.php | 44 +- .../app/Models/TaskLabor.php | 7 + ...add_bundle_fields_to_task_labors_table.php | 32 ++ .../js/Components/EquipmentLookupModal.tsx | 2 +- .../js/Components/EvmSCurveChart.tsx | 504 ++++++++++++++++++ .../js/Components/LaborLookupModal.tsx | 2 +- .../resources/js/Pages/Projects/Index.tsx | 34 +- .../js/Pages/Projects/Modules/Progress.tsx | 33 +- .../resources/js/Pages/Projects/Overview.tsx | 139 ++++- .../resources/js/Pages/Projects/Show.tsx | 25 +- .../resources/js/Pages/Projects/Wizard.tsx | 418 ++++++++++++--- .../Controllers/ProjectProgressController.php | 7 + .../app/Services/ProjectProgressService.php | 395 +++++++++++++- .../tests/Feature/EvmCalculationTest.php | 92 ++++ .../Feature/ProgressMonitoringFeatureTest.php | 241 +++++++++ app/Http/Controllers/DashboardController.php | 17 +- app/Http/Middleware/HandleInertiaRequests.php | 82 ++- .../Dashboard/ContractorDashboardView.tsx | 8 +- .../Dashboard/ExecutiveDashboardView.tsx | 6 +- .../Dashboard/RoleAnalyticsBanner.tsx | 12 +- resources/js/Components/ui/dialog.tsx | 4 +- resources/js/Layouts/AuthenticatedLayout.tsx | 50 +- resources/js/types/index.d.ts | 24 + tests/Feature/RoleBasedActionTest.php | 4 +- 39 files changed, 4103 insertions(+), 573 deletions(-) create mode 100644 Modules/FinancialManagement/database/migrations/2026_08_14_140000_add_payment_proof_to_financial_invoices_table.php create mode 100644 Modules/FinancialManagement/database/migrations/2026_08_14_150000_add_penalty_fields_to_financial_invoices_table.php create mode 100644 Modules/FinancialManagement/resources/js/Components/RetentionPaymentProofModal.tsx create mode 100644 Modules/FinancialManagement/resources/js/Components/RetentionPenaltyModal.tsx create mode 100644 Modules/FinancialManagement/resources/js/Components/RetentionStickyToast.tsx create mode 100644 Modules/FinancialManagement/tests/Feature/RevisedRetentionFlowTest.php create mode 100644 Modules/ProjectManagement/database/migrations/2026_08_14_133000_add_bundle_fields_to_task_labors_table.php create mode 100644 Modules/ProjectManagement/resources/js/Components/EvmSCurveChart.tsx create mode 100644 Modules/ProjectProgress/tests/Feature/EvmCalculationTest.php create mode 100644 Modules/ProjectProgress/tests/Feature/ProgressMonitoringFeatureTest.php diff --git a/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php b/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php index 7367b31..f4d837c 100644 --- a/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php +++ b/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php @@ -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']), ]); } diff --git a/Modules/FinancialManagement/app/Models/FinancialInvoice.php b/Modules/FinancialManagement/app/Models/FinancialInvoice.php index 98a4d23..04aabf9 100644 --- a/Modules/FinancialManagement/app/Models/FinancialInvoice.php +++ b/Modules/FinancialManagement/app/Models/FinancialInvoice.php @@ -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); diff --git a/Modules/FinancialManagement/app/Services/ProgressBillingService.php b/Modules/FinancialManagement/app/Services/ProgressBillingService.php index 6c2b0b8..8057f50 100644 --- a/Modules/FinancialManagement/app/Services/ProgressBillingService.php +++ b/Modules/FinancialManagement/app/Services/ProgressBillingService.php @@ -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). */ diff --git a/Modules/FinancialManagement/database/migrations/2026_08_14_140000_add_payment_proof_to_financial_invoices_table.php b/Modules/FinancialManagement/database/migrations/2026_08_14_140000_add_payment_proof_to_financial_invoices_table.php new file mode 100644 index 0000000..707c650 --- /dev/null +++ b/Modules/FinancialManagement/database/migrations/2026_08_14_140000_add_payment_proof_to_financial_invoices_table.php @@ -0,0 +1,33 @@ +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', + ]); + }); + } +}; diff --git a/Modules/FinancialManagement/database/migrations/2026_08_14_150000_add_penalty_fields_to_financial_invoices_table.php b/Modules/FinancialManagement/database/migrations/2026_08_14_150000_add_penalty_fields_to_financial_invoices_table.php new file mode 100644 index 0000000..125207e --- /dev/null +++ b/Modules/FinancialManagement/database/migrations/2026_08_14_150000_add_penalty_fields_to_financial_invoices_table.php @@ -0,0 +1,33 @@ +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', + ]); + }); + } +}; diff --git a/Modules/FinancialManagement/resources/js/Components/RetentionPaymentProofModal.tsx b/Modules/FinancialManagement/resources/js/Components/RetentionPaymentProofModal.tsx new file mode 100644 index 0000000..ff66129 --- /dev/null +++ b/Modules/FinancialManagement/resources/js/Components/RetentionPaymentProofModal.tsx @@ -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(null); + const [selectedFile, setSelectedFile] = useState(null); + const [notes, setNotes] = useState(''); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(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) => { + 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 ( + !open && onClose()}> + + + + + Send Payment Proof + + + Upload your payment evidence/receipt to submit to Executive for receipt confirmation. + + + + {/* Invoice Summary Box */} +
+
+ Invoice Number: + {invoice.invoice_number} +
+ {invoice.project && ( +
+ Project: + {invoice.project.name} +
+ )} +
+ Total Invoice Amount: + {formatCurrency(invoice.total_amount)} +
+ {invoice.retention_amount !== undefined && ( +
+ {hasPenalty ? 'Total Retention & Penalty Due:' : '10% Retention Remittance:'} + -{formatCurrency(totalRetentionDue)} +
+ )} + {hasPenalty && ( +
+ Breakdown (Base + {invoice.penalty_rate}% Penalty): + {formatCurrency(invoice.retention_amount || 0)} + {formatCurrency(invoice.penalty_amount || 0)} +
+ )} +
+ +
+ {error && ( +
+ + {error} +
+ )} + + {/* File Upload Zone */} +
+ +
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' + }`} + > + + {selectedFile ? ( +
+
+
+ +
+
+

{selectedFile.name}

+

{(selectedFile.size / 1024 / 1024).toFixed(2)} MB

+
+
+ +
+ ) : ( +
+
+ +
+

+ Click to browse or drag payment proof receipt +

+

PDF, PNG, JPG up to 10MB

+
+ )} +
+
+ + {/* Reference / Notes */} +
+ +