Implement 13th Month Pay module with DOLE compliance, back-year filters, and bank CSV export

This commit is contained in:
2026-07-22 10:22:50 +08:00
parent 3bbe070204
commit dfbbeb0ab2
9 changed files with 1132 additions and 0 deletions

View 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);
}
}

View 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');
}
}

View 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);
}
}

View 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']);
});
}
}

View File

@@ -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
View 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 (JanDec), 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 |

View File

@@ -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')) {

View 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>
);
}

View File

@@ -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');