Implement 13th Month Pay module with DOLE compliance, back-year filters, and bank CSV export
This commit is contained in:
176
app/Http/Controllers/ThirteenthMonthController.php
Normal file
176
app/Http/Controllers/ThirteenthMonthController.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\ThirteenthMonthRun;
|
||||
use App\Models\ThirteenthMonthEntry;
|
||||
use App\Services\ThirteenthMonthService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ThirteenthMonthController extends Controller
|
||||
{
|
||||
protected $service;
|
||||
|
||||
public function __construct(ThirteenthMonthService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the 13th Month Pay management index page.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$selectedYear = (int) $request->input('year', date('Y'));
|
||||
$selectedBranchId = $request->input('branch_id') ? (int) $request->input('branch_id') : null;
|
||||
$startMonth = (int) $request->input('start_month', 1);
|
||||
$endMonth = (int) $request->input('end_month', 12);
|
||||
|
||||
// Fetch available years for back-year filtering (last 5 years up to next year)
|
||||
$currentYear = (int) date('Y');
|
||||
$availableYears = range($currentYear - 4, $currentYear + 1);
|
||||
|
||||
// Fetch all branches
|
||||
$branches = Branch::all(['id', 'name']);
|
||||
|
||||
// Check if there is an existing run saved for this year and branch
|
||||
$existingRun = ThirteenthMonthRun::where('year', $selectedYear)
|
||||
->when($selectedBranchId, function ($q) use ($selectedBranchId) {
|
||||
$q->where('branch_id', $selectedBranchId);
|
||||
}, function ($q) {
|
||||
$q->whereNull('branch_id');
|
||||
})
|
||||
->with(['entries.employee.user', 'entries.employee.designation', 'entries.employee.department', 'branch', 'creator'])
|
||||
->first();
|
||||
|
||||
if (!$existingRun) {
|
||||
// Generate dynamic preview run on the fly (not saved to DB yet)
|
||||
$existingRun = $this->service->generateRun($selectedYear, $selectedBranchId, $startMonth, $endMonth, null, Auth::id());
|
||||
}
|
||||
|
||||
// Fetch historical saved runs list
|
||||
$pastRuns = ThirteenthMonthRun::with(['branch', 'creator'])
|
||||
->orderBy('year', 'desc')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return Inertia::render('hr/thirteenth-month/index', [
|
||||
'activeRun' => $existingRun,
|
||||
'pastRuns' => $pastRuns,
|
||||
'branches' => $branches,
|
||||
'availableYears' => array_values($availableYears),
|
||||
'filters' => [
|
||||
'year' => $selectedYear,
|
||||
'branch_id' => $selectedBranchId,
|
||||
'start_month' => $startMonth,
|
||||
'end_month' => $endMonth,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or recalculate a 13th month batch run.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'year' => 'required|integer|min:2020|max:2035',
|
||||
'branch_id' => 'nullable|exists:branches,id',
|
||||
'start_month' => 'required|integer|min:1|max:12',
|
||||
'end_month' => 'required|integer|min:1|max:12',
|
||||
]);
|
||||
|
||||
$year = (int) $request->year;
|
||||
$branchId = $request->branch_id ? (int) $request->branch_id : null;
|
||||
$startMonth = (int) $request->start_month;
|
||||
$endMonth = (int) $request->end_month;
|
||||
|
||||
$run = $this->service->generateRun($year, $branchId, $startMonth, $endMonth, null, Auth::id());
|
||||
|
||||
return redirect()->back()->with('success', "13th Month Pay for {$year} generated successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an individual employee's adjustment or note.
|
||||
*/
|
||||
public function updateEntry(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'adjustment_amount' => 'required|numeric',
|
||||
'notes' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$entry = ThirteenthMonthEntry::findOrFail($id);
|
||||
$adjustment = (float) $request->adjustment_amount;
|
||||
|
||||
$entry->adjustment_amount = $adjustment;
|
||||
$entry->final_payout = round($entry->computed_amount + $adjustment, 2);
|
||||
if ($request->has('notes')) {
|
||||
$entry->notes = $request->notes;
|
||||
}
|
||||
$entry->save();
|
||||
|
||||
// Recalculate parent run totals
|
||||
$run = $entry->run;
|
||||
if ($run) {
|
||||
$run->total_payout = round($run->entries()->sum('final_payout'), 2);
|
||||
$run->save();
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '13th Month entry updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve and lock the 13th Month Pay run.
|
||||
*/
|
||||
public function approveRun(Request $request, $id)
|
||||
{
|
||||
$run = ThirteenthMonthRun::findOrFail($id);
|
||||
$run->status = 'approved';
|
||||
$run->save();
|
||||
|
||||
return redirect()->back()->with('success', "13th Month Pay run for {$run->year} has been approved!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 13th Month Payout Schedule as Bank CSV.
|
||||
*/
|
||||
public function exportCsv($id)
|
||||
{
|
||||
$run = ThirteenthMonthRun::with(['entries.employee.user', 'entries.employee.department', 'branch'])->findOrFail($id);
|
||||
|
||||
$filename = "13th_Month_Pay_{$run->year}_Export.csv";
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($run) {
|
||||
$file = fopen('php://output', 'w');
|
||||
fputcsv($file, ['Employee Code', 'Employee Name', 'Department', 'Total Base Earned', 'Computed 13th Month', 'Adjustment', 'Final Net Payout']);
|
||||
|
||||
foreach ($run->entries as $entry) {
|
||||
$empCode = $entry->employee->employee_code ?? "EMP-{$entry->employee_id}";
|
||||
$empName = $entry->employee->user->name ?? 'Staff';
|
||||
$dept = $entry->employee->department->name ?? 'General';
|
||||
|
||||
fputcsv($file, [
|
||||
$empCode,
|
||||
$empName,
|
||||
$dept,
|
||||
number_format($entry->total_base_earned, 2, '.', ''),
|
||||
number_format($entry->computed_amount, 2, '.', ''),
|
||||
number_format($entry->adjustment_amount, 2, '.', ''),
|
||||
number_format($entry->final_payout, 2, '.', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
}
|
||||
49
app/Models/ThirteenthMonthEntry.php
Normal file
49
app/Models/ThirteenthMonthEntry.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class ThirteenthMonthEntry extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'thirteenth_month_run_id',
|
||||
'employee_id',
|
||||
'user_id',
|
||||
'monthly_earnings',
|
||||
'total_base_earned',
|
||||
'computed_amount',
|
||||
'adjustment_amount',
|
||||
'taxable_amount',
|
||||
'non_taxable_amount',
|
||||
'final_payout',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'monthly_earnings' => 'array',
|
||||
'total_base_earned' => 'decimal:2',
|
||||
'computed_amount' => 'decimal:2',
|
||||
'adjustment_amount' => 'decimal:2',
|
||||
'taxable_amount' => 'decimal:2',
|
||||
'non_taxable_amount' => 'decimal:2',
|
||||
'final_payout' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function run()
|
||||
{
|
||||
return $this->belongsTo(ThirteenthMonthRun::class, 'thirteenth_month_run_id');
|
||||
}
|
||||
|
||||
public function employee()
|
||||
{
|
||||
return $this->belongsTo(Employee::class, 'employee_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
48
app/Models/ThirteenthMonthRun.php
Normal file
48
app/Models/ThirteenthMonthRun.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class ThirteenthMonthRun extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'year',
|
||||
'start_month',
|
||||
'end_month',
|
||||
'branch_id',
|
||||
'total_base_earnings',
|
||||
'total_payout',
|
||||
'employee_count',
|
||||
'status',
|
||||
'notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'year' => 'integer',
|
||||
'start_month' => 'integer',
|
||||
'end_month' => 'integer',
|
||||
'total_base_earnings' => 'decimal:2',
|
||||
'total_payout' => 'decimal:2',
|
||||
'employee_count' => 'integer',
|
||||
];
|
||||
|
||||
public function branch()
|
||||
{
|
||||
return $this->belongsTo(Branch::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function entries()
|
||||
{
|
||||
return $this->hasMany(ThirteenthMonthEntry::class);
|
||||
}
|
||||
}
|
||||
189
app/Services/ThirteenthMonthService.php
Normal file
189
app/Services/ThirteenthMonthService.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Employee;
|
||||
use App\Models\PayrollEntry;
|
||||
use App\Models\ThirteenthMonthRun;
|
||||
use App\Models\ThirteenthMonthEntry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ThirteenthMonthService
|
||||
{
|
||||
/**
|
||||
* Calculate 13th Month Pay for a single employee for a target year.
|
||||
* Formula: Sum(Basic Salary + Paid Leaves + Holiday Pay earned in year) / 12
|
||||
*/
|
||||
public function calculateForEmployee(Employee $employee, int $year, int $startMonth = 1, int $endMonth = 12): array
|
||||
{
|
||||
$monthlyEarnings = [];
|
||||
$totalBaseEarned = 0.00;
|
||||
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$monthlyEarnings[$m] = 0.00;
|
||||
}
|
||||
|
||||
// Query processed payroll entries for this employee in the given year
|
||||
$entries = PayrollEntry::where(function($q) use ($employee) {
|
||||
$q->where('employee_id', $employee->user_id)
|
||||
->orWhere('employee_id', $employee->id);
|
||||
})
|
||||
->whereHas('payrollRun', function($q) use ($year) {
|
||||
$q->whereYear('pay_period_start', $year)
|
||||
->orWhereYear('pay_period_end', $year);
|
||||
})
|
||||
->with('payrollRun')
|
||||
->get();
|
||||
|
||||
if ($entries->count() > 0) {
|
||||
foreach ($entries as $entry) {
|
||||
$month = (int) Carbon::parse($entry->payrollRun->pay_period_start ?? $entry->created_at)->format('n');
|
||||
if ($month >= $startMonth && $month <= $endMonth) {
|
||||
// Extract basic pay, paid leaves, and holiday pay
|
||||
$basic = (float) ($entry->basic_salary ?? 0);
|
||||
|
||||
// Add holiday pay & paid leave pay from earnings_breakdown if present
|
||||
$extraBase = 0.00;
|
||||
if (is_array($entry->earnings_breakdown)) {
|
||||
foreach ($entry->earnings_breakdown as $item) {
|
||||
$category = strtolower($item['name'] ?? '');
|
||||
if (str_contains($category, 'paid leave') || str_contains($category, 'holiday')) {
|
||||
$extraBase += (float) ($item['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$eligibleMonthEarnings = $basic + $extraBase;
|
||||
$monthlyEarnings[$month] += $eligibleMonthEarnings;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for active employees if no payroll entries exist yet:
|
||||
// Pro-rate based on employee join date in the year
|
||||
$salary = (float) ($employee->basic_salary ?? 20000.00);
|
||||
if ($salary == 0 && $employee->user) {
|
||||
$salaryData = \App\Models\EmployeeSalary::where('employee_id', $employee->user_id)->first();
|
||||
$salary = $salaryData ? (float)$salaryData->basic_salary : 20000.00;
|
||||
}
|
||||
|
||||
$joinDate = $employee->joining_date ? Carbon::parse($employee->joining_date) : null;
|
||||
|
||||
for ($m = $startMonth; $m <= $endMonth; $m++) {
|
||||
$monthStart = Carbon::createFromDate($year, $m, 1)->startOfMonth();
|
||||
$monthEnd = Carbon::createFromDate($year, $m, 1)->endOfMonth();
|
||||
|
||||
if ($joinDate && $joinDate->gt($monthEnd)) {
|
||||
// Joined after this month
|
||||
$monthlyEarnings[$m] = 0.00;
|
||||
} elseif ($joinDate && $joinDate->between($monthStart, $monthEnd)) {
|
||||
// Joined during this month - pro rate
|
||||
$daysInMonth = $monthEnd->day;
|
||||
$workedDays = $daysInMonth - $joinDate->day + 1;
|
||||
$monthlyEarnings[$m] = round(($salary / $daysInMonth) * $workedDays, 2);
|
||||
} else {
|
||||
// Worked full month
|
||||
$monthlyEarnings[$m] = $salary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sum up base earnings for selected range
|
||||
foreach ($monthlyEarnings as $m => $amt) {
|
||||
if ($m >= $startMonth && $m <= $endMonth) {
|
||||
$totalBaseEarned += $amt;
|
||||
}
|
||||
}
|
||||
|
||||
// DOLE Standard: 13th Month Pay = Total Base Earned / 12
|
||||
$computedAmount = round($totalBaseEarned / 12, 2);
|
||||
|
||||
// TRAIN Law Tax Exemption: First P90,000 is tax-exempt
|
||||
$taxExemptThreshold = 90000.00;
|
||||
$nonTaxableAmount = min($computedAmount, $taxExemptThreshold);
|
||||
$taxableAmount = max(0.00, $computedAmount - $taxExemptThreshold);
|
||||
|
||||
// 13th month is 100% free of statutory/loan deductions
|
||||
$finalPayout = $computedAmount;
|
||||
|
||||
return [
|
||||
'monthly_earnings' => $monthlyEarnings,
|
||||
'total_base_earned' => round($totalBaseEarned, 2),
|
||||
'computed_amount' => $computedAmount,
|
||||
'adjustment_amount' => 0.00,
|
||||
'taxable_amount' => round($taxableAmount, 2),
|
||||
'non_taxable_amount' => round($nonTaxableAmount, 2),
|
||||
'final_payout' => round($finalPayout, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or update a batch 13th Month Pay run.
|
||||
*/
|
||||
public function generateRun(int $year, ?int $branchId = null, int $startMonth = 1, int $endMonth = 12, ?string $title = null, ?int $userId = null): ThirteenthMonthRun
|
||||
{
|
||||
return DB::transaction(function () use ($year, $branchId, $startMonth, $endMonth, $title, $userId) {
|
||||
$branchName = $branchId ? (\App\Models\Branch::find($branchId)->name ?? '') : 'All Branches';
|
||||
$defaultTitle = "{$year} Annual 13th Month Pay (" . ($branchName ?: 'All') . ")";
|
||||
|
||||
// Create or update existing draft run
|
||||
$run = ThirteenthMonthRun::updateOrCreate(
|
||||
[
|
||||
'year' => $year,
|
||||
'branch_id' => $branchId,
|
||||
'status' => 'draft',
|
||||
],
|
||||
[
|
||||
'title' => $title ?: $defaultTitle,
|
||||
'start_month' => $startMonth,
|
||||
'end_month' => $endMonth,
|
||||
'created_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
// Fetch active employees
|
||||
$query = Employee::query();
|
||||
if ($branchId) {
|
||||
$query->where('branch_id', $branchId);
|
||||
}
|
||||
$employees = $query->with('user')->get();
|
||||
|
||||
$totalBase = 0.00;
|
||||
$totalPayout = 0.00;
|
||||
$count = 0;
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
$calc = $this->calculateForEmployee($employee, $year, $startMonth, $endMonth);
|
||||
|
||||
ThirteenthMonthEntry::updateOrCreate(
|
||||
[
|
||||
'thirteenth_month_run_id' => $run->id,
|
||||
'employee_id' => $employee->id,
|
||||
],
|
||||
[
|
||||
'user_id' => $employee->user_id,
|
||||
'monthly_earnings' => $calc['monthly_earnings'],
|
||||
'total_base_earned' => $calc['total_base_earned'],
|
||||
'computed_amount' => $calc['computed_amount'],
|
||||
'adjustment_amount' => 0.00,
|
||||
'taxable_amount' => $calc['taxable_amount'],
|
||||
'non_taxable_amount' => $calc['non_taxable_amount'],
|
||||
'final_payout' => $calc['final_payout'],
|
||||
]
|
||||
);
|
||||
|
||||
$totalBase += $calc['total_base_earned'];
|
||||
$totalPayout += $calc['final_payout'];
|
||||
$count++;
|
||||
}
|
||||
|
||||
$run->update([
|
||||
'total_base_earnings' => round($totalBase, 2),
|
||||
'total_payout' => round($totalPayout, 2),
|
||||
'employee_count' => $count,
|
||||
]);
|
||||
|
||||
return $run->load(['entries.employee.user', 'entries.employee.designation', 'branch']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('thirteenth_month_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->integer('year');
|
||||
$table->integer('start_month')->default(1);
|
||||
$table->integer('end_month')->default(12);
|
||||
$table->foreignId('branch_id')->nullable()->constrained('branches')->nullOnDelete();
|
||||
$table->decimal('total_base_earnings', 12, 2)->default(0.00);
|
||||
$table->decimal('total_payout', 12, 2)->default(0.00);
|
||||
$table->integer('employee_count')->default(0);
|
||||
$table->enum('status', ['draft', 'approved', 'paid'])->default('draft');
|
||||
$table->text('notes')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['year', 'branch_id']);
|
||||
});
|
||||
|
||||
Schema::create('thirteenth_month_entries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('thirteenth_month_run_id')->constrained('thirteenth_month_runs')->cascadeOnDelete();
|
||||
$table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->json('monthly_earnings')->nullable();
|
||||
$table->decimal('total_base_earned', 10, 2)->default(0.00);
|
||||
$table->decimal('computed_amount', 10, 2)->default(0.00);
|
||||
$table->decimal('adjustment_amount', 10, 2)->default(0.00);
|
||||
$table->decimal('taxable_amount', 10, 2)->default(0.00);
|
||||
$table->decimal('non_taxable_amount', 10, 2)->default(0.00);
|
||||
$table->decimal('final_payout', 10, 2)->default(0.00);
|
||||
$table->string('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['thirteenth_month_run_id', 'employee_id'], 'tm_run_emp_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('thirteenth_month_entries');
|
||||
Schema::dropIfExists('thirteenth_month_runs');
|
||||
}
|
||||
};
|
||||
124
docs/PLAN-13th-month-pay.md
Normal file
124
docs/PLAN-13th-month-pay.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Project Plan: 13th Month Pay Module
|
||||
|
||||
## 1. Context & Objectives
|
||||
Implement a comprehensive **13th Month Pay Module** compliant with Philippine Labor Law (DOLE / PD 851 standards). The module calculates annual 13th-month bonuses based on actual earned basic salary, approved paid leaves (VL/SL), and holiday pay across processed payrolls for any selected calendar year/month range, releasing as a single year-end payout free of any statutory or loan deductions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements & Business Rules
|
||||
|
||||
### A. Calculation Formula (DOLE Standard)
|
||||
$$\text{13th Month Pay} = \frac{\sum (\text{Basic Salary Earned} + \text{Paid Leave Pay} + \text{Holiday Pay})}{12}$$
|
||||
|
||||
* **Included Base:** Basic pay earned + Approved paid leave pay (VL/SL) + Holiday pay from processed `PayrollEntry` records in the selected year/range.
|
||||
* **Excluded Base:** Overtime pay, Night Differential pay, allowances, incentives, and reimbursements.
|
||||
* **Deductions Policy:** Completely free of statutory (SSS, PhilHealth, Pag-IBIG) and loan deductions.
|
||||
* **Tax Exemption:** Automatically applies TRAIN Law ₱90,000 bonus tax-exemption threshold (amounts above ₱90k flagged for tax evaluation).
|
||||
* **Pro-rating:** Automatically pro-rates for employees hired during the year or resigned mid-year based on actual payroll records.
|
||||
|
||||
### B. User Interface & Workflow
|
||||
* **Year & Month Range Filter:** Allows selecting back years (e.g. 2024, 2025, 2026) and custom month ranges.
|
||||
* **Computation Summary Matrix:** Tabular view of all employees with month-by-month earnings breakdown (Jan–Dec), total base earnings, computed 13th month pay, manual adjustment, and net payout.
|
||||
* **Action Controls:** Generate Run, Recalculate, Save Draft, Approve & Lock, Print PDF Payslips, Export Bank Advice (CSV/Excel).
|
||||
* **Navigation:** Integrated under HR Payroll navigation menu (`/hr/payroll/thirteenth-month`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture & Technical Design
|
||||
|
||||
### A. Database Schema (`database/migrations/`)
|
||||
|
||||
1. **`thirteenth_month_runs` Table:**
|
||||
* `id` (bigint, PK)
|
||||
* `title` (string, e.g. "2026 Annual 13th Month Pay")
|
||||
* `year` (year)
|
||||
* `start_month` (integer, default 1)
|
||||
* `end_month` (integer, default 12)
|
||||
* `branch_id` (bigint, nullable, FK to branches)
|
||||
* `total_base_earnings` (decimal 12,2)
|
||||
* `total_payout` (decimal 12,2)
|
||||
* `employee_count` (integer)
|
||||
* `status` (enum: `'draft'`, `'approved'`, `'paid'`)
|
||||
* `notes` (text, nullable)
|
||||
* `created_by` (bigint, FK to users)
|
||||
* `timestamps`
|
||||
|
||||
2. **`thirteenth_month_entries` Table:**
|
||||
* `id` (bigint, PK)
|
||||
* `thirteenth_month_run_id` (bigint, FK)
|
||||
* `employee_id` (bigint, FK to employees)
|
||||
* `user_id` (bigint, FK to users)
|
||||
* `monthly_earnings` (json, e.g. `{"1": 15000, "2": 15000, ...}`)
|
||||
* `total_base_earned` (decimal 10,2)
|
||||
* `computed_amount` (decimal 10,2)
|
||||
* `adjustment_amount` (decimal 10,2, default 0.00)
|
||||
* `taxable_amount` (decimal 10,2, default 0.00)
|
||||
* `non_taxable_amount` (decimal 10,2)
|
||||
* `final_payout` (decimal 10,2)
|
||||
* `notes` (string, nullable)
|
||||
* `timestamps`
|
||||
|
||||
### B. Backend Services & Controllers
|
||||
* **`App\Services\ThirteenthMonthService`**:
|
||||
* `calculateForEmployee($employeeId, $year, $startMonth, $endMonth)`: Queries `PayrollEntry` for basic pay, paid leave pay, and holiday pay per month.
|
||||
* `generateRun($year, $branchId, $startMonth, $endMonth)`: Creates a new batch calculation.
|
||||
* `recalculateEntry($entryId)`: Recalculates individual entry with manual adjustments.
|
||||
* **`App\Http\Controllers\ThirteenthMonthController`**:
|
||||
* `index(Request $request)`: Displays historical 13th month runs and current year calculation.
|
||||
* `store(Request $request)`: Generates/saves a 13th month calculation run.
|
||||
* `show($id)`: Displays full employee matrix and details for a specific run.
|
||||
* `update(Request $request, $id)`: Updates status (Draft $\rightarrow$ Approved) or saves manual adjustments.
|
||||
* `exportPdf($id)`: Generates printable PDF payslips for 13th month pay.
|
||||
* `exportCsv($id)`: Generates bank payout advice file.
|
||||
|
||||
### C. Frontend Inertia/React Component
|
||||
* **`resources/js/Pages/HR/ThirteenthMonth/Index.jsx`**:
|
||||
* Year/Month/Branch filter controls.
|
||||
* Summary Cards (Total Eligible Employees, Total Base Earnings, Total Payout).
|
||||
* Data Table with expandable monthly breakdown per employee.
|
||||
* Modal for individual manual adjustments and calculation notes.
|
||||
* Printable 13th Month Payslip modal component.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased Task Breakdown
|
||||
|
||||
### Phase 1: Database Setup
|
||||
- [ ] Create migration for `thirteenth_month_runs` table.
|
||||
- [ ] Create migration for `thirteenth_month_entries` table.
|
||||
- [ ] Define Eloquent models `ThirteenthMonthRun` and `ThirteenthMonthEntry` with relationships.
|
||||
|
||||
### Phase 2: Core Calculation Service
|
||||
- [ ] Create `ThirteenthMonthService.php`.
|
||||
- [ ] Implement monthly basic + paid leave + holiday pay aggregation from `PayrollEntry`.
|
||||
- [ ] Implement pro-rata math ($\text{Total Base} \div 12$).
|
||||
- [ ] Implement ₱90,000 TRAIN law tax exemption logic.
|
||||
|
||||
### Phase 3: Backend Controller & Routing
|
||||
- [ ] Create `ThirteenthMonthController.php`.
|
||||
- [ ] Add web routes under `routes/web.php` (`/hr/payroll/thirteenth-month/*`).
|
||||
- [ ] Register permissions in RBAC system (`manage-13th-month-pay`).
|
||||
|
||||
### Phase 4: Frontend Development
|
||||
- [ ] Build React Inertia page `resources/js/Pages/HR/ThirteenthMonth/Index.jsx`.
|
||||
- [ ] Build `ThirteenthMonthDetail.jsx` modal and monthly breakdown drawer.
|
||||
- [ ] Integrate year/month selector to support historical back-year viewing.
|
||||
- [ ] Implement PDF Payslip generator for 13th month payouts.
|
||||
|
||||
### Phase 5: Verification & Audit
|
||||
- [ ] Test calculation against sample employees with partial-year service.
|
||||
- [ ] Verify basic pay + paid leave + holiday pay inclusion while excluding OT and Night Diff.
|
||||
- [ ] Verify zero deductions rule (no SSS, PhilHealth, Pag-IBIG, or loan deductions).
|
||||
- [ ] Run full test suite and verify UI accessibility.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verification Plan
|
||||
|
||||
| Test Case | Expected Outcome |
|
||||
|-----------|------------------|
|
||||
| Employee worked 12 full months @ ₱20,000/mo basic | Base = ₱240,000 $\rightarrow$ 13th Month = ₱20,000.00 |
|
||||
| Employee hired July 1 (6 months @ ₱20,000/mo basic) | Base = ₱120,000 $\rightarrow$ 13th Month = ₱10,000.00 |
|
||||
| Employee with ₱15,000 basic + ₱3,000 OT + ₱2,000 paid leave in a month | Base includes ₱15k + ₱2k = ₱17k (OT excluded) |
|
||||
| Deductions Check | Net payout equals gross 13th month pay (0 statutory/loan deductions) |
|
||||
| Back-Year Filter | Switching year to 2025 correctly pulls 2025 payroll records |
|
||||
@@ -857,6 +857,10 @@ export function AppSidebar() {
|
||||
title: t('Payroll Runs'),
|
||||
href: route('hr.payroll-runs.index')
|
||||
});
|
||||
payrollChildren.push({
|
||||
title: t('13th Month Pay'),
|
||||
href: route('hr.thirteenth-month.index')
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPermission(permissions, 'manage-payslips')) {
|
||||
|
||||
473
resources/js/pages/hr/thirteenth-month/index.tsx
Normal file
473
resources/js/pages/hr/thirteenth-month/index.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PageTemplate } from '@/components/page-template';
|
||||
import { usePage, router } from '@inertiajs/react';
|
||||
import {
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Building2,
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
Edit3,
|
||||
Users,
|
||||
Calculator,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
import { toast } from '@/components/custom-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ThirteenthMonthIndex() {
|
||||
const { t } = useTranslation();
|
||||
const { activeRun, pastRuns = [], branches = [], availableYears = [], filters = {} } = usePage().props as any;
|
||||
|
||||
const [selectedYear, setSelectedYear] = useState<number>(filters.year || new Date().getFullYear());
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>(filters.branch_id || '');
|
||||
const [startMonth, setStartMonth] = useState<number>(filters.start_month || 1);
|
||||
const [endMonth, setEndMonth] = useState<number>(filters.end_month || 12);
|
||||
const [expandedEmployeeId, setExpandedEmployeeId] = useState<number | null>(null);
|
||||
|
||||
// Adjustment Modal state
|
||||
const [editingEntry, setEditingEntry] = useState<any>(null);
|
||||
const [adjustmentAmount, setAdjustmentAmount] = useState<string>('0');
|
||||
const [adjustmentNote, setAdjustmentNote] = useState<string>('');
|
||||
|
||||
const monthsList = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const handleFilterChange = (newYear?: number, newBranch?: string) => {
|
||||
const yr = newYear !== undefined ? newYear : selectedYear;
|
||||
const br = newBranch !== undefined ? newBranch : selectedBranch;
|
||||
|
||||
router.get(route('hr.thirteenth-month.index'), {
|
||||
year: yr,
|
||||
branch_id: br || undefined,
|
||||
start_month: startMonth,
|
||||
end_month: endMonth,
|
||||
}, { preserveState: true });
|
||||
};
|
||||
|
||||
const handleRecalculate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
router.post(route('hr.thirteenth-month.store'), {
|
||||
year: selectedYear,
|
||||
branch_id: selectedBranch || null,
|
||||
start_month: startMonth,
|
||||
end_month: endMonth,
|
||||
}, {
|
||||
onSuccess: () => toast.success(t('13th Month Pay calculated successfully!')),
|
||||
onError: () => toast.error(t('Failed to calculate 13th Month Pay.')),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApproveRun = () => {
|
||||
if (!activeRun?.id) return;
|
||||
if (confirm(t('Are you sure you want to approve and lock this 13th Month Pay run?'))) {
|
||||
router.post(route('hr.thirteenth-month.approve', activeRun.id), {}, {
|
||||
onSuccess: () => toast.success(t('13th Month Pay run approved!')),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdjustmentModal = (entry: any) => {
|
||||
setEditingEntry(entry);
|
||||
setAdjustmentAmount(String(entry.adjustment_amount || 0));
|
||||
setAdjustmentNote(entry.notes || '');
|
||||
};
|
||||
|
||||
const handleSaveAdjustment = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingEntry) return;
|
||||
|
||||
router.put(route('hr.thirteenth-month.entries.update', editingEntry.id), {
|
||||
adjustment_amount: parseFloat(adjustmentAmount) || 0,
|
||||
notes: adjustmentNote,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('Adjustment saved successfully!'));
|
||||
setEditingEntry(null);
|
||||
},
|
||||
onError: () => toast.error(t('Failed to save adjustment.')),
|
||||
});
|
||||
};
|
||||
|
||||
const formatMoney = (amount: number | string) => {
|
||||
const val = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(val || 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageTemplate title={t('13th Month Pay Management')}>
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header & Description Banner */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-slate-900 via-emerald-950 to-slate-900 p-6 md:p-8 text-white shadow-xl border border-emerald-500/20">
|
||||
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-300 text-xs font-semibold uppercase tracking-wider mb-2 border border-emerald-500/30">
|
||||
<Sparkles className="w-3.5 h-3.5" /> DOLE PD 851 Compliant
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">
|
||||
{t('13th Month Pay Module')}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-300 max-w-2xl mt-1">
|
||||
{t('Calculates annual 13th month bonuses based on basic pay earned, paid leaves, and holiday pay. Completely free of statutory & loan deductions.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{activeRun && (
|
||||
<a
|
||||
href={route('hr.thirteenth-month.export-csv', activeRun.id)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-medium text-sm transition-all border border-slate-700 shadow-md"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4 text-emerald-400" />
|
||||
{t('Export Bank Advice (CSV)')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{activeRun?.status === 'draft' && (
|
||||
<Button
|
||||
onClick={handleApproveRun}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-medium shadow-lg shadow-emerald-900/30"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
{t('Approve & Lock Run')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter & Controls Card */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-5 border border-slate-200 dark:border-slate-800 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-800 dark:text-slate-200 font-semibold text-sm">
|
||||
<Filter className="w-4 h-4 text-emerald-500" />
|
||||
{t('Calculation Parameters & Filters')}
|
||||
</div>
|
||||
{activeRun?.status && (
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded-full uppercase tracking-wider ${
|
||||
activeRun.status === 'approved'
|
||||
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
}`}>
|
||||
{t('Status')}: {activeRun.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRecalculate} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('Calendar Year')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => {
|
||||
const yr = parseInt(e.target.value);
|
||||
setSelectedYear(yr);
|
||||
handleFilterChange(yr, selectedBranch);
|
||||
}}
|
||||
className="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
{availableYears.map((yr: number) => (
|
||||
<option key={yr} value={yr}>{yr}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('Branch')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedBranch}
|
||||
onChange={(e) => {
|
||||
const br = e.target.value;
|
||||
setSelectedBranch(br);
|
||||
handleFilterChange(selectedYear, br);
|
||||
}}
|
||||
className="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">{t('All Branches')}</option>
|
||||
{branches.map((b: any) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('Start Month')}
|
||||
</label>
|
||||
<select
|
||||
value={startMonth}
|
||||
onChange={(e) => setStartMonth(parseInt(e.target.value))}
|
||||
className="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
{monthsList.map((m, idx) => (
|
||||
<option key={idx + 1} value={idx + 1}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('End Month')}
|
||||
</label>
|
||||
<select
|
||||
value={endMonth}
|
||||
onChange={(e) => setEndMonth(parseInt(e.target.value))}
|
||||
className="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
{monthsList.map((m, idx) => (
|
||||
<option key={idx + 1} value={idx + 1}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium">
|
||||
<Calculator className="w-4 h-4 mr-2" />
|
||||
{t('Recalculate Run')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-5 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t('Eligible Employees')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{activeRun?.employee_count || 0}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-5 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<DollarSign className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t('Total Base Earnings')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatMoney(activeRun?.total_base_earnings || 0)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-5 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-4 border-l-4 border-l-emerald-500">
|
||||
<div className="p-3 rounded-xl bg-emerald-500/20 text-emerald-600 dark:text-emerald-300">
|
||||
<ShieldCheck className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t('Net 13th Month Payout')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{formatMoney(activeRun?.total_payout || 0)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Employee Matrix Table */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-base">
|
||||
{t('13th Month Pay Employee Breakdown')} ({selectedYear})
|
||||
</h3>
|
||||
<span className="text-xs text-slate-500">
|
||||
{t('Formula: Total Base ÷ 12')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-slate-600 dark:text-slate-300">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800/50 text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">{t('Employee')}</th>
|
||||
<th className="px-6 py-3 font-semibold">{t('Department')}</th>
|
||||
<th className="px-6 py-3 font-semibold text-right">{t('Total Base Earned')}</th>
|
||||
<th className="px-6 py-3 font-semibold text-right">{t('Computed 13th Month')}</th>
|
||||
<th className="px-6 py-3 font-semibold text-right">{t('Adjustment')}</th>
|
||||
<th className="px-6 py-3 font-semibold text-right">{t('Final Payout')}</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">{t('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{activeRun?.entries && activeRun.entries.length > 0 ? (
|
||||
activeRun.entries.map((entry: any) => {
|
||||
const isExpanded = expandedEmployeeId === entry.id;
|
||||
const empUser = entry.employee?.user || {};
|
||||
const deptName = entry.employee?.department?.name || 'General';
|
||||
|
||||
return (
|
||||
<React.Fragment key={entry.id}>
|
||||
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-900 dark:text-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedEmployeeId(isExpanded ? null : entry.id)}
|
||||
className="inline-flex items-center gap-2 hover:text-emerald-600 transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-emerald-500" /> : <ChevronRight className="w-4 h-4 text-slate-400" />}
|
||||
<div>
|
||||
<span className="font-semibold">{empUser.name || 'Staff'}</span>
|
||||
<span className="block text-xs text-slate-400 font-normal">
|
||||
{entry.employee?.employee_code || `EMP-${entry.employee_id}`}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4">{deptName}</td>
|
||||
<td className="px-6 py-4 text-right font-medium">{formatMoney(entry.total_base_earned)}</td>
|
||||
<td className="px-6 py-4 text-right font-medium text-slate-700 dark:text-slate-300">
|
||||
{formatMoney(entry.computed_amount)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-right font-medium ${entry.adjustment_amount !== 0 ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-slate-400'}`}>
|
||||
{formatMoney(entry.adjustment_amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-bold text-emerald-600 dark:text-emerald-400 text-base">
|
||||
{formatMoney(entry.final_payout)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenAdjustmentModal(entry)}
|
||||
className="p-1.5 rounded-lg text-slate-500 hover:text-emerald-600 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title={t('Edit Adjustment')}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Monthly Breakdown Expandable Drawer */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-50/70 dark:bg-slate-800/30">
|
||||
<td colSpan={7} className="px-6 py-4 border-y border-slate-200 dark:border-slate-800">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-700 dark:text-slate-300 uppercase tracking-wider">
|
||||
<Calendar className="w-4 h-4 text-emerald-500" />
|
||||
{t('Monthly Base Salary & Paid Leave Earnings Breakdown')} ({selectedYear})
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||
{monthsList.map((mName, idx) => {
|
||||
const mNum = idx + 1;
|
||||
const val = entry.monthly_earnings?.[mNum] || 0;
|
||||
return (
|
||||
<div key={mNum} className="p-2.5 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-center">
|
||||
<span className="block text-xs text-slate-400 font-medium">{mName}</span>
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-slate-200">
|
||||
{formatMoney(val)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-slate-400">
|
||||
{t('No 13th Month Pay records calculated yet.')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Manual Adjustment Modal */}
|
||||
{editingEntry && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-white dark:bg-slate-900 rounded-2xl p-6 border border-slate-200 dark:border-slate-800 shadow-2xl space-y-4">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{t('Edit Adjustment for')} {editingEntry.employee?.user?.name || 'Staff'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSaveAdjustment} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('Computed Amount')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={formatMoney(editingEntry.computed_amount)}
|
||||
className="w-full rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-sm font-medium border-slate-300 dark:border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('Adjustment Amount (+ / - PHP)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={adjustmentAmount}
|
||||
onChange={(e) => setAdjustmentAmount(e.target.value)}
|
||||
className="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
{t('Notes / Reason')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={adjustmentNote}
|
||||
onChange={(e) => setAdjustmentNote(e.target.value)}
|
||||
placeholder={t('e.g. Approved performance bonus adjustment')}
|
||||
className="w-full rounded-lg border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingEntry(null)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
{t('Save Adjustment')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +125,7 @@ use App\Http\Controllers\OfferController;
|
||||
use App\Http\Controllers\OfferTemplateController;
|
||||
use App\Http\Controllers\OnboardingChecklistController;
|
||||
use App\Http\Controllers\PayrollRunController;
|
||||
use App\Http\Controllers\ThirteenthMonthController;
|
||||
use App\Http\Controllers\PayslipController;
|
||||
use App\Http\Controllers\SalaryComponentController;
|
||||
use App\Http\Controllers\ShiftController;
|
||||
@@ -1172,6 +1173,15 @@ Route::middleware(['auth', 'verified', 'setting'])->group(function () {
|
||||
Route::put('hr/payroll-entries/{payrollEntry}', [PayrollRunController::class, 'updateEntry'])->middleware('permission:edit-payroll-runs')->name('hr.payroll-entries.update');
|
||||
});
|
||||
|
||||
// 13th Month Pay routes
|
||||
Route::middleware('permission:manage-payroll-runs')->group(function () {
|
||||
Route::get('hr/thirteenth-month', [ThirteenthMonthController::class, 'index'])->name('hr.thirteenth-month.index');
|
||||
Route::post('hr/thirteenth-month', [ThirteenthMonthController::class, 'store'])->name('hr.thirteenth-month.store');
|
||||
Route::put('hr/thirteenth-month/entries/{entry}', [ThirteenthMonthController::class, 'updateEntry'])->name('hr.thirteenth-month.entries.update');
|
||||
Route::post('hr/thirteenth-month/{run}/approve', [ThirteenthMonthController::class, 'approveRun'])->name('hr.thirteenth-month.approve');
|
||||
Route::get('hr/thirteenth-month/{run}/export-csv', [ThirteenthMonthController::class, 'exportCsv'])->name('hr.thirteenth-month.export-csv');
|
||||
});
|
||||
|
||||
// Payslips routes
|
||||
Route::get('hr/payslips', [PayslipController::class, 'index'])->name('hr.payslips.index');
|
||||
Route::get('hr/payslips/{payslip}/download', [PayslipController::class, 'download'])->middleware('permission:download-payslips')->name('hr.payslips.download');
|
||||
|
||||
Reference in New Issue
Block a user