florida_gym/app/Http/Controllers/StaffController.php
2026-03-11 11:03:12 +05:30

828 lines
35 KiB
PHP

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Staff;
use App\Models\TrainerCommission;
use App\Models\SalaryAdvanceDeduction;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth;
class StaffController extends Controller
{
public function index(Request $request)
{
$user = Auth::guard('web')->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;
$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) {
$path = null;
if ($request->hasFile("documents.{$index}.file")) {
$path = $request->file("documents.{$index}.file")->store('staff_documents', 'public');
}
$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;
$staff->update($validated);
// Handle Documents Update
if ($request->has('documents')) {
$existingDocIds = [];
foreach ($request->input('documents') as $index => $doc) {
$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 {
$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->query('month');
$branch = $staff->branch;
if ($targetMonthKey) {
$nextSettlementMonth = Carbon::parse($targetMonthKey . '-01');
// 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 {
// Find the next unpaid month logically
$lastPayment = \App\Models\StaffPayment::where('staff_id', $id)
->where('payment_type', 'Salary Settlement')
->orderBy('settlement_month', 'desc')
->first();
$joiningDate = Carbon::parse($staff->joining_date);
$currentMonth = Carbon::now()->startOfMonth();
if ($lastPayment) {
$nextSettlementMonth = Carbon::parse($lastPayment->settlement_month . '-01')->addMonth();
} else {
$nextSettlementMonth = $joiningDate->copy()->startOfMonth();
}
// Only allow settlement if the generation date for the month has arrived
$genDay = $branch->salary_generation_day ?? 2;
$generationDate = $nextSettlementMonth->copy()->addMonth()->day(min($genDay, $nextSettlementMonth->copy()->addMonth()->daysInMonth));
if (Carbon::now()->lessThan($generationDate)) {
return response()->json([
'can_settle' => false,
'message' => "Salary for {$nextSettlementMonth->format('F Y')} will be available for settlement on " . $generationDate->format('jS F Y'),
]);
}
}
// 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();
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 = [];
// Loop while generation date for the month has passed
while (true) {
$monthKey = $tempMonth->format('Y-m');
// Calculate generation date for this month
$genDay = $branch->salary_generation_day ?? 2;
$generationDate = $tempMonth->copy()->addMonth()->day(min($genDay, $tempMonth->copy()->addMonth()->daysInMonth));
if (Carbon::now()->lessThan($generationDate)) {
break;
}
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;
}
}