user() ?? Auth::guard('receptionist')->user(); $query = Staff::with(['branch', 'documents']); if ($user && $user->isReceptionist()) { $query->where('branch_id', $user->branch_id); } else if ($branchId = $request->query('branch_id')) { $query->where('branch_id', $branchId); } return response()->json($query->get()); } public function show($id) { $staff = Staff::with(['branch', 'documents'])->findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } return response()->json($staff); } public function store(Request $request) { Log::info('Creating standalone staff record', $request->all()); $validated = $request->validate([ 'full_name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:staff,email', 'phone' => ['required', 'string', 'max:20', 'regex:/^(\+91|91|0)?[6-9]\d{9}$|^(\+971|971|0)?5[024568]\d{7}$/'], 'role' => 'required|string', 'branch_id' => 'required|exists:branches,id', 'joining_date' => 'required|date', 'status' => 'required|string', 'salary_type' => 'required|string', 'salary_amount' => 'required|numeric', 'advance_enabled' => 'nullable|boolean', 'advance_amount' => 'nullable|numeric', 'advance_repayment_mode' => 'nullable|string', 'advance_months' => 'nullable|integer', 'commission_enabled' => 'nullable|boolean', 'commission_amount' => 'nullable|numeric', 'commission_member_count' => 'nullable|integer', 'emirates_id' => 'nullable|string|max:255', 'emirates_id_expiry' => 'nullable|date', 'family_member_name' => 'nullable|string|max:255', 'family_member_relation' => 'nullable|string|max:255', 'family_member_contact' => ['nullable', 'string', 'max:255', 'regex:/^(\+91|91|0)?[6-9]\d{9}$|^(\+971|971|0)?5[024568]\d{7}$/'], 'salary_reminder_enabled' => 'nullable|boolean', 'document_expiry_enabled' => 'nullable|boolean', 'family_members' => 'nullable|array', 'family_members.*.name' => 'required|string|max:255', 'family_members.*.relation' => 'required|string|max:255', 'family_members.*.contact' => ['required', 'string', 'max:255', 'regex:/^(\+91|91|0)?[6-9]\d{9}$|^(\+971|971|0)?5[024568]\d{7}$/'], 'documents' => 'nullable|array', 'documents.*.name' => 'required|string|max:255', 'documents.*.document_number' => 'nullable|string|max:255', 'documents.*.expiry_date' => 'nullable|date', 'documents.*.reminder_days' => 'nullable|integer', ]); // Branch Start Date Validation - REMOVED AS PER USER REQUEST /* $branch = \App\Models\Branch::find($validated['branch_id']); if ($branch && $validated['joining_date'] < $branch->operational_start_date) { return response()->json([ 'errors' => ['joining_date' => ["Joining date cannot be before branch start date ({$branch->operational_start_date})"]] ], 422); } */ if (empty($validated['staff_id_code'])) { $role = strtoupper($validated['role'] ?? 'STAFF'); $count = Staff::count() + 1; $validated['staff_id_code'] = "USR-{$role}-" . str_pad($count, 3, '0', STR_PAD_LEFT); } $status = $validated['status'] ?? 'Active'; $validated['status'] = $status; return DB::transaction(function() use ($validated, $request) { $staff = Staff::create($validated); // Handle Salary Advance Deductions if ($staff->advance_enabled) { if ($staff->advance_repayment_mode === 'Divide by Months') { $months = $staff->advance_months ?: 1; $monthlyDeduction = $staff->advance_amount / $months; SalaryAdvanceDeduction::create([ 'staff_id' => $staff->id, 'advance_amount' => $staff->advance_amount, 'total_months' => $months, 'monthly_deduction' => $monthlyDeduction, 'remaining_amount' => $staff->advance_amount, 'paid_amount' => 0, 'status' => 'Pending' ]); } // Record immediate account debit and expense for the advance $this->recordFinancialsForAdvance($staff, $staff->advance_amount); } // Handle Documents if ($request->has('documents')) { foreach ($request->input('documents') as $index => $doc) { if (empty($doc['name']) && empty($doc['document_number'])) continue; $path = null; if ($request->hasFile("documents.{$index}.file")) { $path = $request->file("documents.{$index}.file")->store('staff_documents', 'public'); } if (!$path) { throw new \Exception("Document file is missing for '{$doc['name']}'"); } $staff->documents()->create([ 'name' => $doc['name'], 'document_number' => $doc['document_number'] ?? null, 'expiry_date' => $doc['expiry_date'] ?? null, 'reminder_days' => $doc['reminder_days'] ?? 30, 'path' => $path, ]); } } return response()->json(['message' => 'Staff created successfully', 'staff' => $staff], 201); }); } public function update(Request $request, $id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } Log::info('Updating standalone staff record', $request->all()); $validated = $request->validate([ 'full_name' => 'nullable|string|max:255', 'email' => 'nullable|string|max:255', 'phone' => ['nullable', 'string', 'max:20', 'regex:/^(\+91|91|0)?[6-9]\d{9}$|^(\+971|971|0)?5[024568]\d{7}$/'], 'designation' => 'nullable|string|max:255', 'role' => 'nullable|string', 'branch_id' => 'nullable|exists:branches,id', 'joining_date' => 'nullable|date', 'status' => 'nullable|string', 'salary_type' => 'nullable|string', 'salary_amount' => 'nullable|numeric', 'salary_cycle' => 'nullable|string', 'advance_enabled' => 'nullable|boolean', 'advance_amount' => 'nullable|numeric', 'advance_repayment_mode' => 'nullable|string', 'advance_months' => 'nullable|integer', 'commission_enabled' => 'nullable|boolean', 'commission_amount' => 'nullable|numeric', 'commission_member_count' => 'nullable|integer', 'apply_from' => 'nullable|string', 'emirates_id' => 'nullable|string|max:255', 'emirates_id_expiry' => 'nullable|date', 'family_member_name' => 'nullable|string|max:255', 'family_member_relation' => 'nullable|string|max:255', 'family_member_contact' => ['nullable', 'string', 'max:255', 'regex:/^(\+91|91|0)?[6-9]\d{9}$|^(\+971|971|0)?5[024568]\d{7}$/'], 'salary_reminder_enabled' => 'nullable|boolean', 'document_expiry_enabled' => 'nullable|boolean', 'family_members' => 'nullable|array', 'family_members.*.name' => 'required|string|max:255', 'family_members.*.relation' => 'required|string|max:255', 'family_members.*.contact' => ['required', 'string', 'max:255', 'regex:/^(\+91|91|0)?[6-9]\d{9}$|^(\+971|971|0)?5[024568]\d{7}$/'], 'documents' => 'nullable|array', 'documents.*.id' => 'nullable|exists:staff_documents,id', 'documents.*.name' => 'required|string|max:255', 'documents.*.document_number' => 'nullable|string|max:255', 'documents.*.expiry_date' => 'nullable|date', 'documents.*.reminder_days' => 'nullable|integer', ]); $status = $validated['status'] ?? 'Active'; $validated['status'] = $status; return DB::transaction(function() use ($staff, $validated, $request) { $staff->update($validated); // Handle Documents Update if ($request->has('documents')) { $existingDocIds = []; foreach ($request->input('documents') as $index => $doc) { if (empty($doc['name']) && empty($doc['document_number']) && !$request->hasFile("documents.{$index}.file")) continue; $path = null; if ($request->hasFile("documents.{$index}.file")) { $path = $request->file("documents.{$index}.file")->store('staff_documents', 'public'); } if (isset($doc['id'])) { $staffDoc = \App\Models\StaffDocument::find($doc['id']); if ($staffDoc && $staffDoc->staff_id == $staff->id) { $updateData = [ 'name' => $doc['name'], 'document_number' => $doc['document_number'] ?? null, 'expiry_date' => $doc['expiry_date'] ?? null, 'reminder_days' => $doc['reminder_days'] ?? 30, ]; if ($path) $updateData['path'] = $path; $staffDoc->update($updateData); $existingDocIds[] = $staffDoc->id; } } else { if (!$path) { throw new \Exception("Document file is missing for '{$doc['name']}'"); } $newDoc = $staff->documents()->create([ 'name' => $doc['name'], 'document_number' => $doc['document_number'] ?? null, 'expiry_date' => $doc['expiry_date'] ?? null, 'reminder_days' => $doc['reminder_days'] ?? 30, 'path' => $path, ]); $existingDocIds[] = $newDoc->id; } } // Delete removed documents $staff->documents()->whereNotIn('id', $existingDocIds)->get()->each(function($doc) { if ($doc->path) Storage::disk('public')->delete($doc->path); $doc->delete(); }); } // Handle Salary Advance Deductions if ($staff->advance_enabled) { // Only record financials if advance amount has increased if ($request->has('advance_amount')) { $oldAdvance = $staff->getOriginal('advance_amount') ?: 0; $newAdvance = $staff->advance_amount; if ($newAdvance > $oldAdvance) { $additionalAmount = $newAdvance - $oldAdvance; $this->recordFinancialsForAdvance($staff, $additionalAmount); } } if ($staff->advance_repayment_mode === 'Divide by Months') { $existing = SalaryAdvanceDeduction::where('staff_id', $staff->id)->where('status', 'Pending')->first(); $months = $staff->advance_months ?: 1; $monthlyDeduction = $staff->advance_amount / $months; if ($existing) { $existing->update([ 'advance_amount' => $staff->advance_amount, 'total_months' => $months, 'monthly_deduction' => $monthlyDeduction, 'remaining_amount' => $staff->advance_amount, ]); } else { SalaryAdvanceDeduction::create([ 'staff_id' => $staff->id, 'advance_amount' => $staff->advance_amount, 'total_months' => $months, 'monthly_deduction' => $monthlyDeduction, 'remaining_amount' => $staff->advance_amount, 'paid_amount' => 0, 'status' => 'Pending' ]); } } } else { // If advance was enabled but now disabled, mark active deduction as Closed if ($staff->getOriginal('advance_enabled')) { SalaryAdvanceDeduction::where('staff_id', $staff->id) ->where('status', 'Pending') ->update(['status' => 'Closed']); } } // Handle Trainer Commission History if ($staff->commission_enabled && $request->has('commission_member_count')) { $effectiveMonth = $request->input('apply_from') === 'next_month' ? Carbon::now()->addMonth()->format('Y-m') : Carbon::now()->format('Y-m'); TrainerCommission::updateOrCreate( [ 'staff_id' => $staff->id, 'effective_month' => $effectiveMonth ], [ 'member_count' => $request->input('commission_member_count'), 'amount_per_head' => $request->input('commission_amount'), 'total_amount' => $request->input('commission_member_count') * $request->input('commission_amount') ] ); } return response()->json(['message' => 'Staff updated successfully', 'staff' => $staff]); }); } public function destroy($id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $staff->delete(); return response()->json(['message' => 'Staff deleted successfully']); } public function getPayments($id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $payments = \App\Models\StaffPayment::where('staff_id', $id)->orderBy('payment_date', 'desc')->get(); return response()->json($payments); } public function storePayment(Request $request, $id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $validated = $request->validate([ 'amount' => 'required|numeric', 'payment_date' => 'required|date', 'payment_type' => 'required|string', 'status' => 'required|string', 'remarks' => 'nullable|string' ]); $payment = \App\Models\StaffPayment::create(array_merge($validated, ['staff_id' => $id])); // Log to Accounts as Debit \App\Models\Account::create([ 'date' => $validated['payment_date'], 'time' => \Carbon\Carbon::now()->toTimeString(), 'credit' => 0, 'debit' => $validated['amount'], 'type' => 'salary', 'accountable_id' => $payment->id, 'accountable_type' => \App\Models\StaffPayment::class, 'description' => "{$validated['payment_type']} for {$staff->full_name}" ]); return response()->json($payment, 201); } public function getSettlementDetails(Request $request, $id) { $staff = Staff::with('branch')->findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $targetMonthKey = $request->input('month') ?? $request->query('month') ?? $request->input('settlement_month'); $branch = $staff->branch; // Find the next unpaid month logically for chronological check $lastPayment = \App\Models\StaffPayment::where('staff_id', $id) ->where('payment_type', 'Salary Settlement') ->orderBy('settlement_month', 'desc') ->first(); $joiningDate = Carbon::parse($staff->joining_date); if ($lastPayment) { $logicalNextMonth = Carbon::parse($lastPayment->settlement_month . '-01')->addMonth(); } else { $logicalNextMonth = $joiningDate->copy()->startOfMonth(); } if ($targetMonthKey) { $requestedMonth = Carbon::parse($targetMonthKey . '-01'); // 1. Chronological Check: Cannot settle a future month if a previous one is unpaid if ($requestedMonth->startOfMonth()->greaterThan($logicalNextMonth->startOfMonth())) { return response()->json([ 'can_settle' => false, 'message' => "Please settle " . $logicalNextMonth->format('F Y') . " before settling " . $requestedMonth->format('F Y') . ".", ]); } $nextSettlementMonth = $requestedMonth; // 2. Verify if already paid $existing = \App\Models\StaffPayment::where('staff_id', $id) ->where('payment_type', 'Salary Settlement') ->where('settlement_month', $targetMonthKey) ->first(); if ($existing) { return response()->json([ 'can_settle' => false, 'message' => "Salary for {$nextSettlementMonth->format('F Y')} is already settled.", ]); } } else { $nextSettlementMonth = $logicalNextMonth; } // 3. Status Check: Only allow settlement if the month has completed if (Carbon::now()->lessThan($nextSettlementMonth->copy()->addMonth()->startOfMonth())) { return response()->json([ 'can_settle' => false, 'message' => "Salary for {$nextSettlementMonth->format('F Y')} is still in progress.", ]); } // Calculate Cycle and Pro-rata $monthKey = $nextSettlementMonth->format('Y-m'); [$start, $end] = $this->getSalaryCycleRange($monthKey, $branch); $baseSalary = $staff->salary_amount ?: 0; $proratedBase = $this->calculateProRatedSalary($baseSalary, $start, $end, $staff->joining_date); $commissionCount = $staff->commission_member_count ?: 0; $commissionAmount = $staff->commission_amount ?: 0; $totalCommission = $commissionCount * $commissionAmount; // Commission pro-rata logic (optional? User didn't specify, but usually commission is per head so no pro-rata) $advanceDeduction = 0; $remainingAdvance = 0; // Find any pending deduction regardless of staff->advance_enabled // This ensures repayments continue even if the feature toggle is flipped $deduction = SalaryAdvanceDeduction::where('staff_id', $id) ->where('status', 'Pending') ->first(); if ($deduction) { if ($staff->advance_repayment_mode === 'Full Next Month') { $advanceDeduction = $deduction->remaining_amount; $remainingAdvance = 0; } else { $advanceDeduction = min($deduction->monthly_deduction, $deduction->remaining_amount); $remainingAdvance = $deduction->remaining_amount - $advanceDeduction; } } $netPayable = $proratedBase + $totalCommission - $advanceDeduction; return response()->json([ 'can_settle' => true, 'staff_name' => $staff->full_name, 'period' => $nextSettlementMonth->format('F Y'), 'settlement_month' => $monthKey, 'cycle_range' => $start->format('j M') . ' - ' . $end->format('j M Y'), 'base_salary' => $baseSalary, 'prorated_base' => round($proratedBase, 2), 'is_prorated' => round($proratedBase, 2) < round($baseSalary, 2), 'salary_type' => $staff->salary_type, 'commission_count' => $commissionCount, 'commission_amount' => $commissionAmount, 'total_commission' => $totalCommission, 'advance_deduction' => $advanceDeduction, 'remaining_advance' => $remainingAdvance, 'net_payable' => round($netPayable, 2) ]); } public function settleSalary(Request $request, $id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $details = $this->getSettlementDetails($request, $id)->getData(true); if (!($details['can_settle'] ?? false)) { return response()->json(['message' => $details['message'] ?? 'Cannot settle at this time'], 400); } $settlementMonth = $details['settlement_month']; $batchId = $request->input('batch_id'); // Double check for existing record $exists = \App\Models\StaffPayment::where('staff_id', $id) ->where('payment_type', 'Salary Settlement') ->where('settlement_month', $settlementMonth) ->exists(); if ($exists) { return response()->json(['message' => 'Salary for this period is already settled'], 400); } $payment = \App\Models\StaffPayment::create([ 'staff_id' => $id, 'amount' => $details['net_payable'], 'payment_date' => Carbon::now()->toDateString(), 'payment_type' => 'Salary Settlement', 'settlement_month' => $settlementMonth, 'status' => 'Paid', 'remarks' => $request->input('remarks') ?? "Salary settlement for {$details['period']}", 'batch_id' => $batchId ]); // Log to Accounts (Debit) \App\Models\Account::create([ 'date' => Carbon::now()->toDateString(), 'time' => Carbon::now()->toTimeString(), 'credit' => 0, 'debit' => $details['net_payable'], 'type' => 'salary', 'accountable_id' => $payment->id, 'accountable_type' => \App\Models\StaffPayment::class, 'description' => "Salary settlement for {$staff->full_name} ({$details['period']})" ]); // Log to General Expenses $salaryCategory = \App\Models\ExpenseCategory::where('name', 'Salary')->first(); \App\Models\Expense::create([ 'date' => Carbon::now()->toDateString(), 'branch_id' => $staff->branch_id, 'expense_category_id' => $salaryCategory ? $salaryCategory->id : 1, 'expense_type' => 'Account', 'amount' => $details['net_payable'], 'remarks' => "Salary payout for {$staff->full_name} ({$details['period']})" . ($batchId ? " (Batch: {$batchId})" : ""), 'batch_id' => $batchId ]); // Update Advance Deduction if applicable $deduction = SalaryAdvanceDeduction::where('staff_id', $id) ->where('status', 'Pending') ->first(); if ($deduction) { $newRemaining = $deduction->remaining_amount - $details['advance_deduction']; $newPaid = ($deduction->paid_amount ?: 0) + $details['advance_deduction']; $deduction->update([ 'remaining_amount' => max(0, $newRemaining), 'paid_amount' => $newPaid, 'status' => $newRemaining <= 0.01 ? 'Closed' : 'Pending' ]); if ($newRemaining <= 0.01) { $staff->update(['advance_enabled' => false]); } } return response()->json(['message' => 'Salary settled successfully', 'payment' => $payment]); } private function recordFinancialsForAdvance($staff, $amount) { if ($amount <= 0) return; // Create a payment record for the advance $payment = \App\Models\StaffPayment::create([ 'staff_id' => $staff->id, 'amount' => $amount, 'payment_date' => Carbon::now()->toDateString(), 'payment_type' => 'Advance', 'status' => 'Paid', 'remarks' => "Salary advance issued" ]); // Log to Accounts (Debit) \App\Models\Account::create([ 'date' => Carbon::now()->toDateString(), 'time' => Carbon::now()->toTimeString(), 'credit' => 0, 'debit' => $amount, 'type' => 'salary', 'accountable_id' => $payment->id, 'accountable_type' => \App\Models\StaffPayment::class, 'description' => "Salary advance for {$staff->full_name}" ]); // Log to General Expenses $advanceCategory = \App\Models\ExpenseCategory::where('name', 'Salary Advance')->first(); \App\Models\Expense::create([ 'date' => Carbon::now()->toDateString(), 'branch_id' => $staff->branch_id, 'expense_category_id' => $advanceCategory ? $advanceCategory->id : 1, 'expense_type' => 'Account', 'amount' => $amount, 'remarks' => "Salary advance issued to {$staff->full_name}" ]); } public function getPayrollStatus($id) { $staff = Staff::findOrFail($id); $joiningDate = Carbon::parse($staff->joining_date)->startOfMonth(); $currentMonth = Carbon::now()->startOfMonth(); $payroll = []; $tempMonth = $joiningDate->copy(); // Get all settlements for this staff $settlements = \App\Models\StaffPayment::where('staff_id', $id) ->where('payment_type', 'Salary Settlement') ->pluck('amount', 'settlement_month') ->toArray(); while ($tempMonth->lessThan($currentMonth)) { $monthKey = $tempMonth->format('Y-m'); $isPaid = isset($settlements[$monthKey]); if (!$isPaid) { $payroll[] = [ 'month' => $tempMonth->format('F Y'), 'month_key' => $monthKey, 'status' => 'Unpaid', 'amount' => null, 'can_settle' => true ]; } $tempMonth->addMonth(); } return response()->json(array_reverse($payroll)); } public function getCommissionHistory($id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $history = TrainerCommission::where('staff_id', $id) ->orderBy('effective_month', 'desc') ->get(); return response()->json($history); } public function getAdvanceHistory($id) { $staff = Staff::findOrFail($id); $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); if ($user && $user->isReceptionist() && $staff->branch_id != $user->branch_id) { return response()->json(['message' => 'Unauthorized'], 403); } $history = SalaryAdvanceDeduction::where('staff_id', $id) ->orderBy('created_at', 'desc') ->get() ->map(function($h) { $schedule = []; $months = $h->total_months ?: 1; $paid = $h->paid_amount ?: 0; $installment = $h->monthly_deduction ?: ($h->advance_amount / $months); // Assuming repayment starts from the month following the advance $startDate = Carbon::parse($h->created_at)->startOfMonth(); for ($i = 0; $i < $months; $i++) { $monthDate = $startDate->copy()->addMonths($i + 1); $isPaid = $paid >= (($i + 1) * $installment - 0.01); $schedule[] = [ 'month' => $monthDate->format('F Y'), 'amount' => round($installment, 2), 'status' => $isPaid ? 'Paid' : 'Pending' ]; } $h->installment_schedule = $schedule; return $h; }); return response()->json($history); } public function getAllPendingSalaries(Request $request) { $user = Auth::guard('web')->user() ?? Auth::guard('receptionist')->user(); $branchId = $user && $user->isReceptionist() ? $user->branch_id : $request->query('branch_id'); $query = Staff::with('branch'); if ($branchId) { $query->where('branch_id', $branchId); } $staffMembers = $query->get(); $pending = []; foreach ($staffMembers as $staff) { $branch = $staff->branch; $joiningDate = Carbon::parse($staff->joining_date)->startOfMonth(); $tempMonth = $joiningDate->copy(); // Get all settlements for this staff $settlements = \App\Models\StaffPayment::where('staff_id', $staff->id) ->where('payment_type', 'Salary Settlement') ->pluck('amount', 'settlement_month') ->toArray(); $staffMonths = []; $currentMonth = Carbon::now()->startOfMonth(); // Loop while the month has completed while ($tempMonth->lessThan($currentMonth)) { $monthKey = $tempMonth->format('Y-m'); // Safety break for extremely old joining dates (limit to 10 years or similar) if ($tempMonth->diffInYears(Carbon::now()) > 10) { $tempMonth->addMonth(); continue; } if (!isset($settlements[$monthKey])) { // Calculate details for this month [$start, $end] = $this->getSalaryCycleRange($monthKey, $branch); $baseSalary = $staff->salary_amount ?: 0; $proratedBase = $this->calculateProRatedSalary($baseSalary, $start, $end, $staff->joining_date); // Commission for this month $commission = TrainerCommission::where('staff_id', $staff->id) ->where('effective_month', '<=', $monthKey) ->orderBy('effective_month', 'desc') ->first(); $commissionAmount = $commission ? $commission->total_amount : 0; // Advance deduction $advanceDed = 0; if ($staff->advance_enabled) { if ($staff->advance_repayment_mode === 'Full Next Month') { $advanceDed = $staff->advance_amount; } else { $deduction = SalaryAdvanceDeduction::where('staff_id', $staff->id)->where('status', 'Pending')->first(); $advanceDed = $deduction ? $deduction->monthly_deduction : 0; } } $netPayable = $proratedBase + $commissionAmount - $advanceDed; $staffMonths[] = [ 'month_key' => $monthKey, 'month_name' => $tempMonth->format('F Y'), 'cycle_range' => $start->format('j M') . ' - ' . $end->format('j M'), 'base_salary' => $baseSalary, 'prorated_base' => round($proratedBase, 2), 'commission' => $commissionAmount, 'advance_deduction' => $advanceDed, 'net_payable' => round($netPayable, 2), ]; } $tempMonth->addMonth(); } if (!empty($staffMonths)) { $totalNet = array_sum(array_column($staffMonths, 'net_payable')); $totalBase = array_sum(array_column($staffMonths, 'prorated_base')); $totalComm = array_sum(array_column($staffMonths, 'commission')); $totalAdv = array_sum(array_column($staffMonths, 'advance_deduction')); $pending[] = [ 'staff_id' => $staff->id, 'staff_name' => $staff->full_name, 'branch' => $staff->branch?->name ?? 'N/A', 'role' => $staff->designation ?? 'Staff', 'base_salary' => $totalBase, 'commission' => $totalComm, 'advance_ded' => $totalAdv, 'net_payable' => $totalNet, 'status' => 'Pending', 'pending_months' => $staffMonths ]; } } return response()->json($pending); } public function bulkSettleSalaries(Request $request) { $payload = $request->validate([ 'settlements' => 'required|array', 'settlements.*.staff_id' => 'required|exists:staff,id', 'settlements.*.month_key' => 'required|string', 'remarks' => 'nullable|string' ]); $batchId = 'BATCH-' . strtoupper(bin2hex(random_bytes(4))); $results = []; foreach ($payload['settlements'] as $item) { $settleReq = new Request(); $settleReq->merge([ 'month' => $item['month_key'], 'batch_id' => $batchId, 'remarks' => $payload['remarks'] ?? 'Bulk salary release' ]); try { $resp = $this->settleSalary($settleReq, $item['staff_id']); $results[] = [ 'staff_id' => $item['staff_id'], 'month' => $item['month_key'], 'status' => 'success' ]; } catch (\Exception $e) { Log::error("Bulk settle error for staff {$item['staff_id']}: " . $e->getMessage()); $results[] = [ 'staff_id' => $item['staff_id'], 'month' => $item['month_key'], 'status' => 'error', 'message' => $e->getMessage() ]; } } return response()->json(['results' => $results, 'batch_id' => $batchId]); } private function getSalaryCycleRange($monthKey, $branch) { $fromDay = $branch->payroll_from_day ?? 1; $toDay = $branch->payroll_to_day ?? 28; $month = Carbon::parse($monthKey . '-01'); if ($fromDay <= $toDay) { // Same month cycle (e.g., 1st to 28th) $start = $month->copy()->day($fromDay)->startOfDay(); $end = $month->copy()->day($toDay)->endOfDay(); } else { // Cross-month cycle (e.g., 25th to 24th next month) // If monthKey is Feb, cycle is Jan 25 to Feb 24 $end = $month->copy()->day($toDay)->endOfDay(); $start = $month->copy()->subMonth()->day($fromDay)->startOfDay(); } return [$start, $end]; } private function calculateProRatedSalary($monthlySalary, $start, $end, $joiningDate) { $joining = Carbon::parse($joiningDate)->startOfDay(); // If joined after the cycle ended, salary is 0 if ($joining->greaterThan($end)) return 0; // If joined before or on the start day, full salary if ($joining->lessThanOrEqualTo($start)) return $monthlySalary; // Prorated calculation $totalDaysInCycle = $start->diffInDays($end) + 1; $workDays = $joining->diffInDays($end) + 1; return ($monthlySalary / $totalDaysInCycle) * $workDays; } }