diff --git a/app/Http/Controllers/ThirteenthMonthController.php b/app/Http/Controllers/ThirteenthMonthController.php new file mode 100644 index 000000000..a0ddabbcc --- /dev/null +++ b/app/Http/Controllers/ThirteenthMonthController.php @@ -0,0 +1,176 @@ +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); + } +} diff --git a/app/Models/ThirteenthMonthEntry.php b/app/Models/ThirteenthMonthEntry.php new file mode 100644 index 000000000..6b7d5ecb4 --- /dev/null +++ b/app/Models/ThirteenthMonthEntry.php @@ -0,0 +1,49 @@ + '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'); + } +} diff --git a/app/Models/ThirteenthMonthRun.php b/app/Models/ThirteenthMonthRun.php new file mode 100644 index 000000000..440ef3293 --- /dev/null +++ b/app/Models/ThirteenthMonthRun.php @@ -0,0 +1,48 @@ + '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); + } +} diff --git a/app/Services/ThirteenthMonthService.php b/app/Services/ThirteenthMonthService.php new file mode 100644 index 000000000..0556bee74 --- /dev/null +++ b/app/Services/ThirteenthMonthService.php @@ -0,0 +1,189 @@ +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']); + }); + } +} diff --git a/database/migrations/2026_07_22_022029_create_thirteenth_month_tables.php b/database/migrations/2026_07_22_022029_create_thirteenth_month_tables.php new file mode 100644 index 000000000..89cc867fa --- /dev/null +++ b/database/migrations/2026_07_22_022029_create_thirteenth_month_tables.php @@ -0,0 +1,59 @@ +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'); + } +}; diff --git a/docs/PLAN-13th-month-pay.md b/docs/PLAN-13th-month-pay.md new file mode 100644 index 000000000..938197af4 --- /dev/null +++ b/docs/PLAN-13th-month-pay.md @@ -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 | diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index f0da6cc60..f5afb9aa6 100755 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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')) { diff --git a/resources/js/pages/hr/thirteenth-month/index.tsx b/resources/js/pages/hr/thirteenth-month/index.tsx new file mode 100644 index 000000000..d7a410869 --- /dev/null +++ b/resources/js/pages/hr/thirteenth-month/index.tsx @@ -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(filters.year || new Date().getFullYear()); + const [selectedBranch, setSelectedBranch] = useState(filters.branch_id || ''); + const [startMonth, setStartMonth] = useState(filters.start_month || 1); + const [endMonth, setEndMonth] = useState(filters.end_month || 12); + const [expandedEmployeeId, setExpandedEmployeeId] = useState(null); + + // Adjustment Modal state + const [editingEntry, setEditingEntry] = useState(null); + const [adjustmentAmount, setAdjustmentAmount] = useState('0'); + const [adjustmentNote, setAdjustmentNote] = useState(''); + + 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 ( + +
+ + {/* Header & Description Banner */} +
+
+
+
+ DOLE PD 851 Compliant +
+

+ {t('13th Month Pay Module')} +

+

+ {t('Calculates annual 13th month bonuses based on basic pay earned, paid leaves, and holiday pay. Completely free of statutory & loan deductions.')} +

+
+ +
+ {activeRun && ( + + + {t('Export Bank Advice (CSV)')} + + )} + + {activeRun?.status === 'draft' && ( + + )} +
+
+
+ + {/* Filter & Controls Card */} +
+
+
+ + {t('Calculation Parameters & Filters')} +
+ {activeRun?.status && ( + + {t('Status')}: {activeRun.status} + + )} +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + {/* Metric Cards */} +
+
+
+ +
+
+

+ {t('Eligible Employees')} +

+

+ {activeRun?.employee_count || 0} +

+
+
+ +
+
+ +
+
+

+ {t('Total Base Earnings')} +

+

+ {formatMoney(activeRun?.total_base_earnings || 0)} +

+
+
+ +
+
+ +
+
+

+ {t('Net 13th Month Payout')} +

+

+ {formatMoney(activeRun?.total_payout || 0)} +

+
+
+
+ + {/* Main Employee Matrix Table */} +
+
+

+ {t('13th Month Pay Employee Breakdown')} ({selectedYear}) +

+ + {t('Formula: Total Base ÷ 12')} + +
+ +
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + {/* Monthly Breakdown Expandable Drawer */} + {isExpanded && ( + + + + )} + + ); + }) + ) : ( + + + + )} + +
{t('Employee')}{t('Department')}{t('Total Base Earned')}{t('Computed 13th Month')}{t('Adjustment')}{t('Final Payout')}{t('Actions')}
+ + {deptName}{formatMoney(entry.total_base_earned)} + {formatMoney(entry.computed_amount)} + + {formatMoney(entry.adjustment_amount)} + + {formatMoney(entry.final_payout)} + + +
+
+
+ + {t('Monthly Base Salary & Paid Leave Earnings Breakdown')} ({selectedYear}) +
+ +
+ {monthsList.map((mName, idx) => { + const mNum = idx + 1; + const val = entry.monthly_earnings?.[mNum] || 0; + return ( +
+ {mName} + + {formatMoney(val)} + +
+ ); + })} +
+
+
+ {t('No 13th Month Pay records calculated yet.')} +
+
+
+ +
+ + {/* Manual Adjustment Modal */} + {editingEntry && ( +
+
+

+ {t('Edit Adjustment for')} {editingEntry.employee?.user?.name || 'Staff'} +

+ +
+
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/routes/web.php b/routes/web.php index ad39e538d..80f8ae43d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');