diff --git a/.gitignore b/.gitignore index 540ff997d..5e508f77d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ /storage /payslip storage/ -/agent*.sql +*.sql *.sql.gz +vendor/ + diff --git a/app/Helpers/helper.php b/app/Helpers/helper.php index 53884ffd0..14c8302d1 100644 --- a/app/Helpers/helper.php +++ b/app/Helpers/helper.php @@ -1143,6 +1143,30 @@ if (!function_exists('getCompanyAndUsersId')) { } } +if (!function_exists('authBranchId')) { + /** + * Get the branch ID that the authenticated user is restricted to. + * Returns null for company owners and superadmins (multi-branch access), + * or for users with no assigned branch (company-wide access). + * + * @return int|null + */ + function authBranchId(): ?int + { + $user = Auth::user(); + if (!$user) { + return null; + } + + // Company owners and superadmins always have full cross-branch company access + if (in_array($user->type, ['company', 'superadmin']) || $user->hasRole(['company', 'superadmin'])) { + return null; + } + + return $user->branch_id ?? $user->employee?->branch_id ?? null; + } +} + // Recursive Function For Get the All Users of the company in tree hierarchy if (!function_exists('getAllCompanyUsers')) { function getAllCompanyUsers($companyId, &$allUsers = []) diff --git a/app/Http/Controllers/AnnouncementController.php b/app/Http/Controllers/AnnouncementController.php index aaf09ef69..ce5dbfbaa 100644 --- a/app/Http/Controllers/AnnouncementController.php +++ b/app/Http/Controllers/AnnouncementController.php @@ -56,7 +56,15 @@ class AnnouncementController extends Controller } // Handle branch filter - if ($request->has('branch_id') && !empty($request->branch_id)) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $query->where(function ($q) use ($scopedBranchId) { + $q->where('is_company_wide', true) + ->orWhereHas('branches', function ($q) use ($scopedBranchId) { + $q->where('branches.id', $scopedBranchId); + }); + }); + } elseif ($request->has('branch_id') && !empty($request->branch_id)) { $query->where(function ($q) use ($request) { $q->where('is_company_wide', true) ->orWhereHas('branches', function ($q) use ($request) { @@ -128,14 +136,20 @@ class AnnouncementController extends Controller $announcements = $query->paginate($request->per_page ?? 10); // Get departments for filter dropdown - $departments = Department::whereIn('created_by', getCompanyAndUsersId()) - ->select('id', 'name') - ->get(); + $departmentsQuery = Department::whereIn('created_by', getCompanyAndUsersId()) + ->select('id', 'name'); + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->get(); // Get branches for filter dropdown - $branches = Branch::whereIn('created_by', getCompanyAndUsersId()) - ->select('id', 'name') - ->get(); + $branchesQuery = Branch::whereIn('created_by', getCompanyAndUsersId()) + ->select('id', 'name'); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(); // Get categories for filter dropdown $categories = Announcement::whereIn('created_by', getCompanyAndUsersId()) @@ -200,15 +214,22 @@ class AnnouncementController extends Controller ->pluck('category') ->toArray(); + $scopedBranchId = authBranchId(); // Get departments for filter - $departments = Department::whereIn('created_by', getCompanyAndUsersId()) - ->select('id', 'name') - ->get(); + $departmentsQuery = Department::whereIn('created_by', getCompanyAndUsersId()) + ->select('id', 'name'); + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->get(); // Get branches for filter - $branches = Branch::whereIn('created_by', getCompanyAndUsersId()) - ->select('id', 'name') - ->get(); + $branchesQuery = Branch::whereIn('created_by', getCompanyAndUsersId()) + ->select('id', 'name'); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(); // Get employee for marking announcements as read $employee = null; diff --git a/app/Http/Controllers/AttendanceRecordController.php b/app/Http/Controllers/AttendanceRecordController.php index f0db7c172..fb2f24cdb 100644 --- a/app/Http/Controllers/AttendanceRecordController.php +++ b/app/Http/Controllers/AttendanceRecordController.php @@ -41,9 +41,18 @@ class AttendanceRecordController extends Controller } if (Auth::user()->can('manage-attendance-records')) { + $scopedBranchId = authBranchId(); $query = AttendanceRecord::with(['employee.employee.branch', 'shift', 'creator']) - ->where(function ($q) { - if (Auth::user()->can('manage-any-attendance-records')) { + ->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->where(function ($subQ) use ($scopedBranchId) { + $subQ->where('attendance_records.branch_id', $scopedBranchId) + ->orWhereHas('employee.employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); + }); + } elseif (Auth::user()->can('manage-any-attendance-records')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-attendance-records')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; @@ -72,7 +81,17 @@ class AttendanceRecordController extends Controller } // Handle branch filter - if ($request->has('branch_id') && ! empty($request->branch_id) && $request->branch_id !== 'all') { + if ($scopedBranchId) { + $query->where(function ($q) use ($scopedBranchId) { + $q->where('attendance_records.branch_id', $scopedBranchId) + ->orWhere(function ($subQ) use ($scopedBranchId) { + $subQ->whereNull('attendance_records.branch_id') + ->whereHas('employee.employee', function ($empQ) use ($scopedBranchId) { + $empQ->where('branch_id', $scopedBranchId); + }); + }); + }); + } elseif ($request->has('branch_id') && ! empty($request->branch_id) && $request->branch_id !== 'all') { $branchId = $request->branch_id; $query->where(function ($q) use ($branchId) { $q->where('attendance_records.branch_id', $branchId) @@ -177,7 +196,11 @@ class AttendanceRecordController extends Controller ]; } - $branches = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId())->get(['id', 'name']); + $branchesQuery = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId()); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); return Inertia::render('hr/attendance-records/index', [ 'attendanceRecords' => $attendanceRecords, @@ -197,8 +220,11 @@ class AttendanceRecordController extends Controller { // Get employees for filter dropdown (compatible with getFilteredEmployees logic) $employeeQuery = Employee::whereIn('created_by', getCompanyAndUsersId()); + $scopedBranchId = authBranchId(); - if (Auth::user()->can('manage-attendance-records') && !Auth::user()->can('manage-any-attendance-records')) { + if ($scopedBranchId) { + $employeeQuery->where('branch_id', $scopedBranchId); + } elseif (Auth::user()->can('manage-attendance-records') && !Auth::user()->can('manage-any-attendance-records')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; if ($branchId) { $employeeQuery->where('branch_id', $branchId); @@ -1130,7 +1156,13 @@ class AttendanceRecordController extends Controller ->where('status', 'completed'); }); - if (Auth::user()->can('manage-any-attendance-records')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $query->whereIn('created_by', getCompanyAndUsersId()) + ->whereHas('employee', function ($q) use ($scopedBranchId) { + $q->where('branch_id', $scopedBranchId); + }); + } elseif (Auth::user()->can('manage-any-attendance-records')) { $query->whereIn('created_by', getCompanyAndUsersId()); } else { $myBranchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; @@ -1149,7 +1181,18 @@ class AttendanceRecordController extends Controller }); } - if ($branch_id) { + if ($scopedBranchId) { + $query->where(function ($q) use ($scopedBranchId, $startDate, $endDate) { + $q->whereHas('employee', function ($subQ) use ($scopedBranchId) { + $subQ->where('branch_id', $scopedBranchId); + })->orWhereIn('id', function ($subQ) use ($scopedBranchId, $startDate, $endDate) { + $subQ->select('employee_id') + ->from('attendance_records') + ->where('branch_id', $scopedBranchId) + ->whereBetween('date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')]); + }); + }); + } elseif ($branch_id) { $query->where(function ($q) use ($branch_id, $startDate, $endDate) { $q->whereHas('employee', function ($subQ) use ($branch_id) { $subQ->where('branch_id', $branch_id); @@ -1476,9 +1519,18 @@ class AttendanceRecordController extends Controller }); // Get departments for filter - $departments = \App\Models\Department::select('id', 'name')->get(); + $departmentsQuery = \App\Models\Department::whereIn('created_by', getCompanyAndUsersId()) + ->where('status', 'active'); + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->select('id', 'name')->get(); - $branches = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId())->get(['id', 'name']); + $branchesQuery = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId()); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); return Inertia::render('hr/attendance-records/calendar', [ 'users' => $users, diff --git a/app/Http/Controllers/BiometricAttendanceController.php b/app/Http/Controllers/BiometricAttendanceController.php index ff7b485f0..8be2d6f78 100644 --- a/app/Http/Controllers/BiometricAttendanceController.php +++ b/app/Http/Controllers/BiometricAttendanceController.php @@ -163,10 +163,17 @@ class BiometricAttendanceController extends Controller $canManageAny = $isSuperOrCompany || $user->can('manage-any-biometric-attendance'); $canManageBranch = $user->can('manage-biometric-attendance'); + $scopedBranchId = authBranchId(); $query = \App\Models\BiometricAttendance::with(['branch']); // Filter by permissions - if ($canManageAny) { + if ($scopedBranchId) { + $branchEmpCodes = \App\Models\Employee::where('branch_id', $scopedBranchId)->whereNotNull('biometric_emp_id')->pluck('biometric_emp_id'); + $query->where(function($q) use ($scopedBranchId, $branchEmpCodes) { + $q->where('branch_id', $scopedBranchId) + ->orWhereIn('biometric_emp_id', $branchEmpCodes); + }); + } elseif ($canManageAny) { // Full company access } elseif ($canManageBranch) { $branchId = $user->branch_id ?? $user->employee?->branch_id; @@ -323,8 +330,16 @@ class BiometricAttendanceController extends Controller $canManageAny = $isSuperOrCompany || $user->can('manage-any-biometric-attendance'); $canManageBranch = $user->can('manage-biometric-attendance'); - // If user is a self-service employee only, verify they are viewing their own record - if (!$canManageAny && !$canManageBranch) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $targetEmp = Employee::where('biometric_emp_id', $employeeCode)->first(); + if ($targetEmp && (int)$targetEmp->branch_id !== (int)$scopedBranchId) { + return response()->json([ + 'success' => false, + 'message' => 'Permission denied' + ], 403); + } + } elseif (!$canManageAny && !$canManageBranch) { $biometricEmpId = $user->employee?->biometric_emp_id; if (empty($biometricEmpId) || $biometricEmpId !== $employeeCode) { return response()->json([ diff --git a/app/Http/Controllers/BranchController.php b/app/Http/Controllers/BranchController.php index c9379e08b..ea3bb18bf 100644 --- a/app/Http/Controllers/BranchController.php +++ b/app/Http/Controllers/BranchController.php @@ -13,8 +13,11 @@ class BranchController extends Controller public function index(Request $request) { if (Auth::user()->can('manage-branches')) { - $query = Branch::where(function ($q) { - if (Auth::user()->can('manage-any-branches')) { + $scopedBranchId = authBranchId(); + $query = Branch::where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->where('id', $scopedBranchId); + } elseif (Auth::user()->can('manage-any-branches')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-own-branches')) { $q->where('created_by', Auth::id()); @@ -56,6 +59,10 @@ class BranchController extends Controller public function store(Request $request) { if (Auth::user()->can('create-branches')) { + if (authBranchId()) { + return redirect()->back()->with('error', __('Permission Denied. Branch-assigned accounts cannot create new branches.')); + } + try { $validated = $request->validate([ 'name' => 'required|string|max:255', @@ -99,6 +106,11 @@ class BranchController extends Controller public function update(Request $request, $branchId) { if (Auth::user()->can('edit-branches')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$branchId !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You can only manage your assigned branch.')); + } + $branch = Branch::where('id', $branchId) ->whereIn('created_by', getCompanyAndUsersId()) ->first(); @@ -149,6 +161,10 @@ class BranchController extends Controller public function destroy($branchId) { if (Auth::user()->can('delete-branches')) { + if (authBranchId()) { + return redirect()->back()->with('error', __('Permission Denied. Branch-assigned accounts cannot delete branches.')); + } + $branch = Branch::where('id', $branchId) ->whereIn('created_by', getCompanyAndUsersId()) ->first(); @@ -179,6 +195,10 @@ class BranchController extends Controller public function toggleStatus($branchId) { if (Auth::user()->can('toggle-status-branches')) { + if (authBranchId()) { + return redirect()->back()->with('error', __('Permission Denied. Branch-assigned accounts cannot modify branch status.')); + } + $branch = Branch::where('id', $branchId) ->whereIn('created_by', getCompanyAndUsersId()) ->first(); diff --git a/app/Http/Controllers/DepartmentController.php b/app/Http/Controllers/DepartmentController.php index e8c79f41f..92b4a86e8 100644 --- a/app/Http/Controllers/DepartmentController.php +++ b/app/Http/Controllers/DepartmentController.php @@ -13,9 +13,12 @@ class DepartmentController extends Controller public function index(Request $request) { if (Auth::user()->can('manage-departments')) { - - $query = Department::with(['branch', 'creator'])->where(function ($q) { - if (Auth::user()->can('manage-any-departments')) { + $scopedBranchId = authBranchId(); + $query = Department::with(['branch', 'creator'])->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->where('branch_id', $scopedBranchId); + } elseif (Auth::user()->can('manage-any-departments')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-own-departments')) { $q->where('created_by', Auth::id()); @@ -33,7 +36,9 @@ class DepartmentController extends Controller } // Handle branch filter - if ($request->has('branch_id') && !empty($request->branch_id) && $request->branch_id !== 'all') { + if ($scopedBranchId) { + $query->where('branch_id', $scopedBranchId); + } elseif ($request->has('branch_id') && !empty($request->branch_id) && $request->branch_id !== 'all') { $query->where('branch_id', $request->branch_id); } @@ -57,9 +62,12 @@ class DepartmentController extends Controller $departments = $query->paginate($request->per_page ?? 10); // Get branches for filter dropdown - $branches = Branch::whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name']); + $branchesQuery = Branch::whereIn('created_by', getCompanyAndUsersId()) + ->where('status', 'active'); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); return Inertia::render('hr/departments/index', [ 'departments' => $departments, @@ -74,6 +82,11 @@ class DepartmentController extends Controller public function store(Request $request) { if (Auth::user()->can('create-departments')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $request->merge(['branch_id' => $scopedBranchId]); + } + $validated = $request->validate([ 'name' => 'required|string|max:255', 'branch_id' => 'required|exists:branches,id', @@ -81,6 +94,10 @@ class DepartmentController extends Controller 'status' => 'nullable|in:active,inactive', ]); + if ($scopedBranchId && (int)$validated['branch_id'] !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You can only create departments in your assigned branch.')); + } + $validated['created_by'] = creatorId(); $validated['status'] = $validated['status'] ?? 'active'; @@ -114,9 +131,16 @@ class DepartmentController extends Controller public function update(Request $request, $departmentId) { if (Auth::user()->can('edit-departments')) { - $department = Department::where('id', $departmentId) - ->whereIn('created_by', getCompanyAndUsersId()) - ->first(); + $scopedBranchId = authBranchId(); + $departmentQuery = Department::where('id', $departmentId) + ->whereIn('created_by', getCompanyAndUsersId()); + + if ($scopedBranchId) { + $departmentQuery->where('branch_id', $scopedBranchId); + $request->merge(['branch_id' => $scopedBranchId]); + } + + $department = $departmentQuery->first(); if ($department) { try { @@ -127,6 +151,10 @@ class DepartmentController extends Controller 'status' => 'nullable|in:active,inactive', ]); + if ($scopedBranchId && (int)$validated['branch_id'] !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You can only update departments in your assigned branch.')); + } + // Check if branch belongs to the current user's company $branch = Branch::where('id', $validated['branch_id']) ->whereIn('created_by', getCompanyAndUsersId()) @@ -164,9 +192,15 @@ class DepartmentController extends Controller public function destroy($departmentId) { if (Auth::user()->can('delete-departments')) { - $department = Department::where('id', $departmentId) - ->whereIn('created_by', getCompanyAndUsersId()) - ->first(); + $scopedBranchId = authBranchId(); + $departmentQuery = Department::where('id', $departmentId) + ->whereIn('created_by', getCompanyAndUsersId()); + + if ($scopedBranchId) { + $departmentQuery->where('branch_id', $scopedBranchId); + } + + $department = $departmentQuery->first(); if ($department) { try { @@ -196,9 +230,15 @@ class DepartmentController extends Controller public function toggleStatus($departmentId) { if (Auth::user()->can('toggle-status-departments')) { - $department = Department::where('id', $departmentId) - ->whereIn('created_by', getCompanyAndUsersId()) - ->first(); + $scopedBranchId = authBranchId(); + $departmentQuery = Department::where('id', $departmentId) + ->whereIn('created_by', getCompanyAndUsersId()); + + if ($scopedBranchId) { + $departmentQuery->where('branch_id', $scopedBranchId); + } + + $department = $departmentQuery->first(); if ($department) { try { diff --git a/app/Http/Controllers/EmployeeController.php b/app/Http/Controllers/EmployeeController.php index e1b4c3648..b348c5eaf 100644 --- a/app/Http/Controllers/EmployeeController.php +++ b/app/Http/Controllers/EmployeeController.php @@ -29,13 +29,19 @@ class EmployeeController extends Controller { if (Auth::user()->can('manage-employees')) { $authUser = Auth::user(); + $scopedBranchId = authBranchId(); $query = User::with(['employee.branch', 'employee.department', 'employee.designation']) - ->where(function ($q) use ($authUser) { - if ($authUser->can('manage-any-employees')) { + ->where(function ($q) use ($authUser, $scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->whereHas('employee', function($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); + } elseif ($authUser->can('manage-any-employees')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif ($authUser->can('manage-employees')) { // Scope to branch if they only have 'manage-employees' - $branchId = $authUser->employee->branch_id ?? null; + $branchId = $authUser->branch_id ?? $authUser->employee->branch_id ?? null; if ($branchId) { $q->whereHas('employee', function($eq) use ($branchId) { $eq->where('branch_id', $branchId); @@ -70,7 +76,11 @@ class EmployeeController extends Controller } // Handle branch filter - if ($request->has('branch') && !empty($request->branch) && $request->branch !== 'all') { + if ($scopedBranchId) { + $query->whereHas('employee', function ($q) use ($scopedBranchId) { + $q->where('branch_id', $scopedBranchId); + }); + } elseif ($request->has('branch') && !empty($request->branch) && $request->branch !== 'all') { $query->whereHas('employee', function ($q) use ($request) { $q->where('branch_id', $request->branch); }); @@ -118,20 +128,32 @@ class EmployeeController extends Controller $branchesQuery = Branch::whereIn('created_by', getCompanyAndUsersId()) ->where('status', 'active'); - if (!$authUser->can('manage-any-employees') && isset($authUser->employee->branch_id)) { + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } elseif (!$authUser->can('manage-any-employees') && isset($authUser->employee->branch_id)) { $branchesQuery->where('id', $authUser->employee->branch_id); } $branches = $branchesQuery->get(['id', 'name']); - $departments = Department::with('branch') + $departmentsQuery = Department::with('branch') ->whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name', 'branch_id']); + ->where('status', 'active'); - $designations = Designation::with('department') + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->get(['id', 'name', 'branch_id']); + + $designationsQuery = Designation::with('department') ->whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name', 'department_id']); + ->where('status', 'active'); + + if ($scopedBranchId) { + $designationsQuery->whereHas('department', function ($dq) use ($scopedBranchId) { + $dq->where('branch_id', $scopedBranchId); + }); + } + $designations = $designationsQuery->get(['id', 'name', 'department_id']); // Get plan limits for company users and staff users (only in SaaS mode) $planLimits = null; @@ -176,28 +198,41 @@ class EmployeeController extends Controller public function create() { if (Auth::user()->can('create-employees')) { + $scopedBranchId = authBranchId(); + // Get branches, departments, designations, and document types for the form - $branches = Branch::whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name']); + $branchesQuery = Branch::whereIn('created_by', getCompanyAndUsersId()) + ->where('status', 'active'); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); - $departments = Department::with('branch') + $departmentsQuery = Department::with('branch') ->whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name', 'branch_id']); + ->where('status', 'active'); + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->get(['id', 'name', 'branch_id']); - $designations = Designation::with('department') + $designationsQuery = Designation::with('department') ->whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name', 'department_id']); + ->where('status', 'active'); + if ($scopedBranchId) { + $designationsQuery->whereHas('department', function ($dq) use ($scopedBranchId) { + $dq->where('branch_id', $scopedBranchId); + }); + } + $designations = $designationsQuery->get(['id', 'name', 'department_id']); $documentTypes = DocumentType::whereIn('created_by', getCompanyAndUsersId()) ->get(['id', 'name', 'is_required']); $shifts = \App\Models\Shift::whereIn('created_by', getCompanyAndUsersId()) ->where('status', 'active') - ->where(function($q) { - $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; + ->where(function($q) use ($scopedBranchId) { + $branchId = $scopedBranchId ?? Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; if ($branchId && !Auth::user()->can('manage-any-shifts')) { $q->where('branch_id', $branchId)->orWhereNull('branch_id'); } @@ -227,6 +262,11 @@ class EmployeeController extends Controller public function store(Request $request) { if (Auth::user()->can('create-employees')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $request->merge(['branch_id' => $scopedBranchId]); + } + try { // Validate basic information $validator = Validator::make($request->all(), [ @@ -455,6 +495,11 @@ class EmployeeController extends Controller public function show(Employee $employee) { if (Auth::user()->can('view-employees')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$employee->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot view employees outside your assigned branch.')); + } + // Check if employee belongs to current company $companyUserIds = getCompanyAndUsersId(); if (!in_array($employee->created_by, $companyUserIds)) { @@ -487,6 +532,11 @@ class EmployeeController extends Controller public function edit(Employee $employee) { if (Auth::user()->can('edit-employees')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$employee->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot edit employees outside your assigned branch.')); + } + // Check if employee belongs to current company $companyUserIds = getCompanyAndUsersId(); if (!in_array($employee->created_by, $companyUserIds)) { @@ -508,27 +558,38 @@ class EmployeeController extends Controller } // Get branches, departments, designations, and document types for the form - $branches = Branch::whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name']); + $branchesQuery = Branch::whereIn('created_by', getCompanyAndUsersId()) + ->where('status', 'active'); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); - $departments = Department::with('branch') + $departmentsQuery = Department::with('branch') ->whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name', 'branch_id']); + ->where('status', 'active'); + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->get(['id', 'name', 'branch_id']); - $designations = Designation::with('department') + $designationsQuery = Designation::with('department') ->whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->get(['id', 'name', 'department_id']); + ->where('status', 'active'); + if ($scopedBranchId) { + $designationsQuery->whereHas('department', function ($dq) use ($scopedBranchId) { + $dq->where('branch_id', $scopedBranchId); + }); + } + $designations = $designationsQuery->get(['id', 'name', 'department_id']); $documentTypes = DocumentType::whereIn('created_by', getCompanyAndUsersId()) ->get(['id', 'name', 'is_required']); $shifts = \App\Models\Shift::whereIn('created_by', getCompanyAndUsersId()) ->where('status', 'active') - ->where(function($q) { - $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; + ->where(function($q) use ($scopedBranchId) { + $branchId = $scopedBranchId ?? Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; if ($branchId && !Auth::user()->can('manage-any-shifts')) { $q->where('branch_id', $branchId)->orWhereNull('branch_id'); } @@ -559,6 +620,15 @@ class EmployeeController extends Controller public function update(Request $request, Employee $employee) { if (Auth::user()->can('edit-employees')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$employee->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot update employees outside your assigned branch.')); + } + + if ($scopedBranchId) { + $request->merge(['branch_id' => $scopedBranchId]); + } + // Check if employee belongs to current company $companyUserIds = getCompanyAndUsersId(); if (!in_array($employee->created_by, $companyUserIds)) { @@ -750,6 +820,7 @@ class EmployeeController extends Controller public function destroy($userId) { if (Auth::user()->can('delete-employees')) { + $scopedBranchId = authBranchId(); try { $user = User::with('employee')->where('id', $userId)->whereIn('created_by', getCompanyAndUsersId())->first(); @@ -757,6 +828,10 @@ class EmployeeController extends Controller return redirect()->back()->with('error', __('Employee not found')); } + if ($scopedBranchId && (int)$user->employee->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot delete employees outside your assigned branch.')); + } + $employee = $user->employee; // Delete documents first diff --git a/app/Http/Controllers/LeaveApplicationController.php b/app/Http/Controllers/LeaveApplicationController.php index e4e608ff2..18fef614a 100644 --- a/app/Http/Controllers/LeaveApplicationController.php +++ b/app/Http/Controllers/LeaveApplicationController.php @@ -23,6 +23,7 @@ class LeaveApplicationController extends Controller $isSelfServiceOnly = !$canManageAny && !$canManageBranch; $isMyLeavesView = $request->boolean('my_leaves') || $isSelfServiceOnly; + $scopedBranchId = authBranchId(); $query = LeaveApplication::with(['employee.employee', 'leaveType', 'leavePolicy', 'approver', 'creator']); if ($isMyLeavesView) { @@ -30,6 +31,11 @@ class LeaveApplicationController extends Controller $q->where('employee_id', $user->id) ->orWhere('created_by', $user->id); }); + } elseif ($scopedBranchId) { + $query->whereIn('created_by', getCompanyAndUsersId()) + ->whereHas('employee.employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); } elseif ($canManageAny) { $query->whereIn('created_by', getCompanyAndUsersId()); } elseif ($canManageBranch) { @@ -120,8 +126,11 @@ class LeaveApplicationController extends Controller { $user = Auth::user(); $employeeQuery = Employee::whereIn('created_by', getCompanyAndUsersId()); + $scopedBranchId = authBranchId(); - if (!$user->can('manage-any-leave-applications')) { + if ($scopedBranchId) { + $employeeQuery->where('branch_id', $scopedBranchId); + } elseif (!$user->can('manage-any-leave-applications')) { if ($user->can('manage-leave-applications')) { $branchId = $user->branch_id ?? $user->employee?->branch_id ?? null; if ($branchId) { @@ -350,11 +359,19 @@ class LeaveApplicationController extends Controller 'manager_comments' => 'nullable|string', ]); - $leaveApplication = LeaveApplication::where('id', $leaveApplicationId) + $leaveApplication = LeaveApplication::with('employee.employee')->where('id', $leaveApplicationId) ->whereIn('created_by', getCompanyAndUsersId()) ->first(); if ($leaveApplication) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $applicantBranchId = $leaveApplication->employee?->employee?->branch_id; + if ($applicantBranchId && (int)$applicantBranchId !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot manage leave applications outside your branch.')); + } + } + // If the leave belongs to the currently logged in user, require manage-own-leave-applications permission if ($leaveApplication->employee_id == Auth::id()) { if (!Auth::user()->can('manage-own-leave-applications')) { @@ -415,10 +432,16 @@ class LeaveApplicationController extends Controller public function export() { if (Auth::user()->can('export-leave-applications')) { + $scopedBranchId = authBranchId(); try { $leaveApplications = LeaveApplication::with(['employee.employee', 'leaveType', 'approver']) - ->where(function ($q) { - if (Auth::user()->can('manage-any-leave-applications')) { + ->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->whereHas('employee.employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); + } elseif (Auth::user()->can('manage-any-leave-applications')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-leave-applications')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee?->branch_id ?? null; diff --git a/app/Http/Controllers/LeaveBalanceController.php b/app/Http/Controllers/LeaveBalanceController.php index b1239fb2e..3bd62aef9 100644 --- a/app/Http/Controllers/LeaveBalanceController.php +++ b/app/Http/Controllers/LeaveBalanceController.php @@ -17,9 +17,15 @@ class LeaveBalanceController extends Controller public function index(Request $request) { if (Auth::user()->can('manage-leave-balances') || Auth::user()->can('view-leave-balances')) { + $scopedBranchId = authBranchId(); $query = LeaveBalance::with(['employee', 'leaveType', 'leavePolicy', 'creator']) - ->where(function ($q) { - if (Auth::user()->can('manage-any-leave-balances')) { + ->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->whereHas('employee.employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); + } elseif (Auth::user()->can('manage-any-leave-balances')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-leave-balances')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; @@ -123,8 +129,11 @@ class LeaveBalanceController extends Controller { // Get employees for filter dropdown (compatible with getFilteredEmployees logic) $employeeQuery = Employee::whereIn('created_by', getCompanyAndUsersId()); + $scopedBranchId = authBranchId(); - if (Auth::user()->can('manage-leave-balances') && !Auth::user()->can('manage-any-leave-balances')) { + if ($scopedBranchId) { + $employeeQuery->where('branch_id', $scopedBranchId); + } elseif (Auth::user()->can('manage-leave-balances') && !Auth::user()->can('manage-any-leave-balances')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; if ($branchId) { $employeeQuery->where('branch_id', $branchId); diff --git a/app/Http/Controllers/PayslipController.php b/app/Http/Controllers/PayslipController.php index a1dbe0fa2..3222dc5ef 100644 --- a/app/Http/Controllers/PayslipController.php +++ b/app/Http/Controllers/PayslipController.php @@ -45,8 +45,14 @@ class PayslipController extends Controller } if (Auth::user()->can('manage-payslips')) { - $query = Payslip::with(['employee', 'payrollEntry.payrollRun', 'creator'])->where(function ($q) { - if (Auth::user()->can('manage-any-payslips')) { + $scopedBranchId = authBranchId(); + $query = Payslip::with(['employee', 'payrollEntry.payrollRun', 'creator'])->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->whereHas('employee.employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); + } elseif (Auth::user()->can('manage-any-payslips')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-payslips')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; @@ -107,9 +113,14 @@ class PayslipController extends Controller $payslips = $query->paginate($request->per_page ?? 10); // Get employees for filter dropdown - $employees = User::where('type', 'employee') - ->whereIn('created_by', getCompanyAndUsersId()) - ->get(['id', 'name']); + $employeesQuery = User::where('type', 'employee') + ->whereIn('created_by', getCompanyAndUsersId()); + if ($scopedBranchId) { + $employeesQuery->whereHas('employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); + }); + } + $employees = $employeesQuery->get(['id', 'name']); return Inertia::render('hr/payslips/index', [ 'payslips' => $payslips, diff --git a/app/Http/Controllers/ShiftController.php b/app/Http/Controllers/ShiftController.php index ae5e73c15..0bd8e0c63 100644 --- a/app/Http/Controllers/ShiftController.php +++ b/app/Http/Controllers/ShiftController.php @@ -12,8 +12,14 @@ class ShiftController extends Controller public function index(Request $request) { if (Auth::user()->can('manage-shifts')) { - $query = Shift::with(['creator'])->where(function ($q) { - if (Auth::user()->can('manage-any-shifts')) { + $scopedBranchId = authBranchId(); + $query = Shift::with(['creator'])->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->where(function($q2) use ($scopedBranchId) { + $q2->where('branch_id', $scopedBranchId) + ->orWhereNull('branch_id'); // company wide shifts + })->whereIn('created_by', getCompanyAndUsersId()); + } elseif (Auth::user()->can('manage-any-shifts')) { $q->whereIn('created_by', getCompanyAndUsersId()); } else { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; @@ -67,8 +73,13 @@ class ShiftController extends Controller $shifts = $query->paginate($request->per_page ?? 9); // Stats always calculated from ALL records — never affected by filters or pagination - $allShifts = Shift::where(function ($q) { - if (Auth::user()->can('manage-any-shifts')) { + $allShifts = Shift::where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->where(function($q2) use ($scopedBranchId) { + $q2->where('branch_id', $scopedBranchId) + ->orWhereNull('branch_id'); // company wide shifts + })->whereIn('created_by', getCompanyAndUsersId()); + } elseif (Auth::user()->can('manage-any-shifts')) { $q->whereIn('created_by', getCompanyAndUsersId()); } else { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; @@ -90,7 +101,11 @@ class ShiftController extends Controller 'day' => (clone $allShifts)->where('is_night_shift', false)->count(), ]; - $branches = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId())->get(['id', 'name']); + $branchesQuery = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId()); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); return Inertia::render('hr/shifts/index', [ 'shifts' => $shifts, @@ -122,7 +137,10 @@ class ShiftController extends Controller $validated['status'] = $validated['status'] ?? 'active'; $validated['is_night_shift'] = $validated['is_night_shift'] ?? false; - if (!Auth::user()->can('manage-any-shifts')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $validated['branch_id'] = $scopedBranchId; + } elseif (!Auth::user()->can('manage-any-shifts')) { $validated['branch_id'] = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; } else { $validated['branch_id'] = ($request->input('branch_id') === 'none' || empty($request->input('branch_id'))) ? null : $request->input('branch_id'); @@ -175,7 +193,10 @@ class ShiftController extends Controller 'branch_id' => 'nullable', ]); - if (!Auth::user()->can('manage-any-shifts')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $validated['branch_id'] = $scopedBranchId; + } elseif (!Auth::user()->can('manage-any-shifts')) { $validated['branch_id'] = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; } else { $validated['branch_id'] = ($request->input('branch_id') === 'none' || empty($request->input('branch_id'))) ? null : $request->input('branch_id'); @@ -277,7 +298,12 @@ class ShiftController extends Controller $q->where('employee_status', 'active'); }); - if (!Auth::user()->can('manage-any-shifts')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $query->whereHas('employee', function($q) use ($scopedBranchId) { + $q->where('branch_id', $scopedBranchId); + }); + } elseif (!Auth::user()->can('manage-any-shifts')) { $branchId = Auth::user()->branch_id ?? Auth::user()->employee->branch_id ?? null; if ($branchId) { $query->whereHas('employee', function($q) use ($branchId) { @@ -399,8 +425,20 @@ class ShiftController extends Controller ]; }); - $departments = \App\Models\Department::select('id', 'name')->get(); - $allShifts = \App\Models\Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->get(); + $departmentsQuery = \App\Models\Department::whereIn('created_by', getCompanyAndUsersId()) + ->where('status', 'active'); + if ($scopedBranchId) { + $departmentsQuery->where('branch_id', $scopedBranchId); + } + $departments = $departmentsQuery->select('id', 'name')->get(); + + $allShiftsQuery = \App\Models\Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active'); + if ($scopedBranchId) { + $allShiftsQuery->where(function($sq) use ($scopedBranchId) { + $sq->where('branch_id', $scopedBranchId)->orWhereNull('branch_id'); + }); + } + $allShifts = $allShiftsQuery->get(); return Inertia::render('hr/shifts/calendar', [ 'users' => $users, @@ -421,6 +459,14 @@ class ShiftController extends Controller public function assignShift(Request $request) { if (Auth::user()->can('manage-shifts')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId) { + $targetEmp = Employee::where('user_id', $request->employee_id)->first(); + if ($targetEmp && (int)$targetEmp->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot assign shifts to employees outside your branch.')); + } + } + $validated = $request->validate([ 'employee_id' => 'required|exists:users,id', 'start_date' => 'required|date', diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index b81148a1d..7a2aaf970 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -25,9 +25,12 @@ class UserController extends BaseController abort(403, 'Unauthorized Access Prevented'); } - // $userQuery = User::withPermissionCheck()->with(['roles', 'creator'])->latest(); - $userQuery = User::with(['roles', 'creator'])->where(function ($q) { - if (Auth::user()->can('manage-any-users')) { + $scopedBranchId = authBranchId(); + $userQuery = User::with(['roles', 'creator'])->where(function ($q) use ($scopedBranchId) { + if ($scopedBranchId) { + $q->whereIn('created_by', getCompanyAndUsersId()) + ->where('branch_id', $scopedBranchId); + } elseif (Auth::user()->can('manage-any-users')) { $q->whereIn('created_by', getCompanyAndUsersId()); } elseif (Auth::user()->can('manage-own-users')) { $q->where('created_by', Auth::id()); @@ -121,7 +124,11 @@ class UserController extends BaseController } } - $branches = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId())->get(['id', 'name']); + $branchesQuery = \App\Models\Branch::whereIn('created_by', getCompanyAndUsersId()); + if ($scopedBranchId) { + $branchesQuery->where('id', $scopedBranchId); + } + $branches = $branchesQuery->get(['id', 'name']); return Inertia::render('users/index', [ 'users' => $users, @@ -179,13 +186,16 @@ class UserController extends BaseController $created_by = auth()->id(); } + $scopedBranchId = authBranchId(); + $assignedBranchId = $scopedBranchId ?: $request->branch_id; + $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), 'created_by' => creatorId(), 'lang' => $userLang, - 'branch_id' => $request->branch_id, + 'branch_id' => $assignedBranchId, ]); if ($user && $request->roles) { @@ -222,10 +232,17 @@ class UserController extends BaseController public function update(UserRequest $request, User $user) { if (Auth::user()->can('edit-users')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$user->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot edit users outside your assigned branch.')); + } + if ($user) { $user->name = $request->name; $user->email = $request->email; - if ($request->has('branch_id')) { + if ($scopedBranchId) { + $user->branch_id = $scopedBranchId; + } elseif ($request->has('branch_id')) { $user->branch_id = $request->branch_id; } @@ -261,6 +278,11 @@ class UserController extends BaseController public function destroy(User $user) { if (Auth::user()->can('delete-users')) { + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$user->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. You cannot delete users outside your assigned branch.')); + } + if ($user) { $user->delete(); return redirect()->route('users.index')->with('success', __('User deleted with roles')); diff --git a/database/migrations/2026_06_05_120911_swap_biometric_employee_branches.php b/database/migrations/2026_06_05_120911_swap_biometric_employee_branches.php index 9f86795ba..b694c2f9c 100644 --- a/database/migrations/2026_06_05_120911_swap_biometric_employee_branches.php +++ b/database/migrations/2026_06_05_120911_swap_biometric_employee_branches.php @@ -10,23 +10,25 @@ return new class extends Migration */ public function up(): void { - DB::transaction(function () { - // 1. Align raw biometric logs branch association for ALL employees to match their profile branch - DB::statement(" - UPDATE biometric_attendances ba - JOIN employees e ON ba.biometric_emp_id = e.biometric_emp_id COLLATE utf8mb4_unicode_ci - SET ba.branch_id = e.branch_id - WHERE ba.branch_id != e.branch_id OR ba.branch_id IS NULL - "); + if (DB::getDriverName() !== 'sqlite') { + DB::transaction(function () { + // 1. Align raw biometric logs branch association for ALL employees to match their profile branch + DB::statement(" + UPDATE biometric_attendances ba + JOIN employees e ON ba.biometric_emp_id = e.biometric_emp_id COLLATE utf8mb4_unicode_ci + SET ba.branch_id = e.branch_id + WHERE ba.branch_id != e.branch_id OR ba.branch_id IS NULL + "); - // 2. Align synced attendance records branch association for ALL employees to match their profile branch - DB::statement(" - UPDATE attendance_records ar - JOIN employees e ON ar.employee_id = e.user_id - SET ar.branch_id = e.branch_id - WHERE ar.branch_id != e.branch_id OR ar.branch_id IS NULL - "); - }); + // 2. Align synced attendance records branch association for ALL employees to match their profile branch + DB::statement(" + UPDATE attendance_records ar + JOIN employees e ON ar.employee_id = e.user_id + SET ar.branch_id = e.branch_id + WHERE ar.branch_id != e.branch_id OR ar.branch_id IS NULL + "); + }); + } } /** diff --git a/database/migrations/2026_06_30_051111_seed_leave_data_to_prod.php b/database/migrations/2026_06_30_051111_seed_leave_data_to_prod.php index d4206e32a..260ef4999 100644 --- a/database/migrations/2026_06_30_051111_seed_leave_data_to_prod.php +++ b/database/migrations/2026_06_30_051111_seed_leave_data_to_prod.php @@ -131,7 +131,9 @@ INSERT IGNORE INTO `leave_applications` (`id`, `employee_id`, `leave_type_id`, ` (72, 9, 3, 4, \'2026-06-08\', \'2026-06-08\', 1, \'lwop\', NULL, \'approved\', NULL, 1, \'2026-06-04 12:06:36\', 1, \'2026-06-04 12:06:02\', \'2026-06-04 12:06:36\'), (73, 14, 1, 2, \'2026-06-10\', \'2026-06-10\', 1, \'vl\', NULL, \'approved\', NULL, 1, \'2026-06-04 12:06:34\', 1, \'2026-06-04 12:06:29\', \'2026-06-04 12:06:34\');'; - DB::unprepared($sql); + if (DB::getDriverName() !== 'sqlite') { + DB::unprepared($sql); + } } /** diff --git a/database/migrations/2026_07_20_034506_fix_reversed_biometric_punches.php b/database/migrations/2026_07_20_034506_fix_reversed_biometric_punches.php index 2cccd4c8c..905ce86ed 100644 --- a/database/migrations/2026_07_20_034506_fix_reversed_biometric_punches.php +++ b/database/migrations/2026_07_20_034506_fix_reversed_biometric_punches.php @@ -13,28 +13,30 @@ return new class extends Migration */ public function up(): void { - // Find all attendance records where clock_out is greater than clock_in by more than 12 hours - // This targets night shift punches that were accidentally reversed by the previous algorithm - $records = AttendanceRecord::whereNotNull('biometric_id') - ->whereRaw('TIME_TO_SEC(clock_out) - TIME_TO_SEC(clock_in) > 43200') - ->get(); - - foreach ($records as $record) { - $oldIn = $record->clock_in; - $oldOut = $record->clock_out; - - // Swap them - $record->clock_in = $oldOut; - $record->clock_out = $oldIn; - - // Save the swapped times - $record->save(); - - // Reprocess attendance so that hours, late, overtime are correct based on the new times - try { - $record->processAttendance(); - } catch (\Exception $e) { - Log::error("Failed to process attendance for record {$record->id}: " . $e->getMessage()); + if (DB::getDriverName() !== 'sqlite') { + // Find all attendance records where clock_out is greater than clock_in by more than 12 hours + // This targets night shift punches that were accidentally reversed by the previous algorithm + $records = AttendanceRecord::whereNotNull('biometric_id') + ->whereRaw('TIME_TO_SEC(clock_out) - TIME_TO_SEC(clock_in) > 43200') + ->get(); + + foreach ($records as $record) { + $oldIn = $record->clock_in; + $oldOut = $record->clock_out; + + // Swap them + $record->clock_in = $oldOut; + $record->clock_out = $oldIn; + + // Save the swapped times + $record->save(); + + // Reprocess attendance so that hours, late, overtime are correct based on the new times + try { + $record->processAttendance(); + } catch (\Exception $e) { + Log::error("Failed to process attendance for record {$record->id}: " . $e->getMessage()); + } } } } diff --git a/docs/PLAN-hr-branch-scoping.md b/docs/PLAN-hr-branch-scoping.md new file mode 100644 index 000000000..489733552 --- /dev/null +++ b/docs/PLAN-hr-branch-scoping.md @@ -0,0 +1,105 @@ +# Plan: Branch Scoping & Isolation for HR and Branch-Assigned Accounts + +## 1. Overview & Problem Statement +When a user creates a new account (e.g., with role **HR** or **Manager**) and assigns it to a specific branch (`branch_id`), the user can currently see other branches, departments, and employees across the company. +Per business rules: **An account assigned to a branch must only see and manage their own assigned branch and its associated records.** + +--- + +## 2. Root Cause Analysis +1. **Direct `users.branch_id` vs `employee.branch_id` Disconnect**: + - When an account is created in `UserController@store`, `branch_id` is assigned directly to the `users` table (`$user->branch_id`). + - Several existing controllers check `$authUser->employee->branch_id`. For staff accounts created via User Management, `$authUser->employee` is `null`, causing branch scoping checks to fall through. +2. **Overly Broad `manage-any-*` Permissions**: + - The HR role is granted permissions like `manage-any-branches`, `manage-any-employees`, etc. + - Controllers handle `manage-any-*` by querying `whereIn('created_by', getCompanyAndUsersId())`, which loads all branches and employees in the entire company without checking if the authenticated user is scoped to a specific branch. +3. **Dropdowns & Selectors Not Branch-Constrained**: + - `BranchController@index`, `DepartmentController@index`, `EmployeeController@create`, and filter dropdowns query `Branch::whereIn('created_by', getCompanyAndUsersId())` without filtering by the user's assigned branch. + +--- + +## 3. Architecture & Design + +### A. Centralized Branch Resolution Helper +Add a helper in `app/Helpers/helper.php` or `User` model: +```php +if (!function_exists('authBranchId')) { + function authBranchId(): ?int + { + $user = Auth::user(); + if (!$user || in_array($user->type, ['company', 'superadmin'])) { + return null; // Company admin and superadmin have multi-branch access + } + return $user->branch_id ?? $user->employee?->branch_id ?? null; + } +} +``` + +### B. Controller Scoping Rules +When `authBranchId()` returns a non-null integer: + +1. **`BranchController`**: + - `index()`: Only return the branch matching `id = authBranchId()`. + - `show()`, `edit()`, `update()`, `destroy()`: Enforce that `id == authBranchId()`. If not, deny with 403 / redirect back. + - Prevent creating additional branches if user is a branch-scoped user. + +2. **`DepartmentController`**: + - `index()`: Filter departments where `branch_id = authBranchId()`. + - Branch filter dropdown: Only list the user's assigned branch. + - `store()` / `update()`: Force `branch_id = authBranchId()`. + +3. **`EmployeeController`**: + - `index()`: Scope employees query to `whereHas('employee', fn($eq) => $eq->where('branch_id', authBranchId()))`. + - Branch filter dropdown: Only show the assigned branch. + - Department filter dropdown: Only show departments under the assigned branch. + - `create()` / `edit()`: `branches` list only contains `id = authBranchId()`. `departments` list only contains departments of `authBranchId()`. + +4. **`AttendanceRecordController` & `BiometricAttendanceController`**: + - Enforce branch query scoping to `authBranchId()`. + - Restrict branch filter options to the assigned branch. + +5. **`LeaveApplicationController` & `LeaveBalanceController`**: + - Enforce employee listing and leave management within `authBranchId()`. + +6. **`PayrollRunController` & `PayslipController`**: + - Enforce payslip generation and visibility limited to employees under `authBranchId()`. + +--- + +## 4. Implementation Steps + +### Phase 1: Core Helper & User Model +- Define `authBranchId()` in `app/Helpers/helper.php`. +- Add helper method `$user->getAssignedBranchId()` on `App\Models\User`. + +### Phase 2: Branch & Department Scoping +- Update `BranchController.php` to restrict queries and CRUD operations to `authBranchId()` when set. +- Update `DepartmentController.php` to restrict queries, dropdowns, and validation to `authBranchId()` when set. + +### Phase 3: Employee Scoping +- Update `EmployeeController.php`: + - Fix `index()` query to respect `$authUser->branch_id` in addition to `$authUser->employee->branch_id`. + - Fix `create()` and `edit()` dropdown queries for branches and departments. + - Validate on `store()` and `update()` that branch cannot be overridden to a foreign branch. + +### Phase 4: Attendance, Leaves & Supporting Modules +- Audit and apply `authBranchId()` to: + - `AttendanceRecordController.php` + - `BiometricAttendanceController.php` + - `LeaveApplicationController.php` + - `ShiftController.php` + - `AnnouncementController.php` + +--- + +## 5. Verification Checklist +- [ ] Create a test user assigned to Branch A with role `HR`. +- [ ] Log in as the new HR user: + - [ ] Visit `/hr/branches`: Only Branch A is displayed. + - [ ] Visit `/hr/departments`: Only departments under Branch A are displayed. + - [ ] Visit `/hr/employees`: Only employees assigned to Branch A are visible. + - [ ] Click "Add Employee": Branch selector only has Branch A (or auto-locked), and department dropdown only lists Branch A's departments. + - [ ] Visit Attendance / Biometrics: Only Branch A attendance logs and biometric records are visible. +- [ ] Log in as Company Admin: + - [ ] Verify Company Admin still sees ALL branches, departments, and employees without restriction. +- [ ] Automated tests: Write a Feature test in `tests/Feature/BranchScopedHrAccessTest.php` testing multi-branch data isolation. diff --git a/tests/Feature/BranchScopedAccessTest.php b/tests/Feature/BranchScopedAccessTest.php new file mode 100644 index 000000000..7f65bf501 --- /dev/null +++ b/tests/Feature/BranchScopedAccessTest.php @@ -0,0 +1,322 @@ + 'manage-branches']); + Permission::firstOrCreate(['name' => 'manage-any-branches']); + Permission::firstOrCreate(['name' => 'manage-departments']); + Permission::firstOrCreate(['name' => 'manage-any-departments']); + Permission::firstOrCreate(['name' => 'manage-employees']); + Permission::firstOrCreate(['name' => 'manage-any-employees']); + Permission::firstOrCreate(['name' => 'manage-payslips']); + Permission::firstOrCreate(['name' => 'manage-any-payslips']); + } + + public function test_auth_branch_id_helper_resolution() + { + $company = User::create([ + 'name' => 'Company Admin', + 'email' => 'company_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'company', + ]); + + $superadmin = User::create([ + 'name' => 'Super Admin', + 'email' => 'admin_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'superadmin', + ]); + + $branch = Branch::create([ + 'name' => 'Branch Alpha', + 'created_by' => $company->id, + ]); + + $userDirectBranch = User::create([ + 'name' => 'Branch User', + 'email' => 'hr_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'hr', + 'branch_id' => $branch->id, + 'created_by' => $company->id, + ]); + + $this->actingAs($company); + $this->assertNull(authBranchId()); + + $this->actingAs($superadmin); + $this->assertNull(authBranchId()); + + $this->actingAs($userDirectBranch); + $this->assertEquals($branch->id, authBranchId()); + } + + public function test_branch_scoped_user_only_sees_assigned_branch() + { + $company = User::create([ + 'name' => 'Company Admin', + 'email' => 'company_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'company', + ]); + $company->givePermissionTo('manage-branches'); + $company->givePermissionTo('manage-any-branches'); + + $branchA = Branch::create([ + 'name' => 'Branch Alpha', + 'created_by' => $company->id, + ]); + + $branchB = Branch::create([ + 'name' => 'Branch Beta', + 'created_by' => $company->id, + ]); + + $hrUser = User::create([ + 'name' => 'HR User Branch A', + 'email' => 'hr_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'hr', + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + $hrUser->givePermissionTo('manage-branches'); + $hrUser->givePermissionTo('manage-any-branches'); + + // Test HR accessing branches index + $response = $this->actingAs($hrUser)->get(route('hr.branches.index')); + $response->assertStatus(200); + $response->assertSee('Branch Alpha'); + $response->assertDontSee('Branch Beta'); + + // Test Company Admin accessing branches index + $companyResponse = $this->actingAs($company)->get(route('hr.branches.index')); + $companyResponse->assertStatus(200); + $companyResponse->assertSee('Branch Alpha'); + $companyResponse->assertSee('Branch Beta'); + } + + public function test_branch_scoped_user_only_sees_assigned_branch_departments_and_employees() + { + $company = User::create([ + 'name' => 'Company Admin', + 'email' => 'company_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'company', + ]); + + $branchA = Branch::create([ + 'name' => 'Branch Alpha', + 'created_by' => $company->id, + ]); + + $branchB = Branch::create([ + 'name' => 'Branch Beta', + 'created_by' => $company->id, + ]); + + $deptA = Department::create([ + 'name' => 'Dept Alpha Finance', + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + + $deptB = Department::create([ + 'name' => 'Dept Beta Logistics', + 'branch_id' => $branchB->id, + 'created_by' => $company->id, + ]); + + // Employee in Branch A + $userA = User::create([ + 'name' => 'Employee Alpha User', + 'email' => 'alpha_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'employee', + 'created_by' => $company->id, + ]); + Employee::create([ + 'user_id' => $userA->id, + 'employee_id' => 'EMP-A-' . rand(100, 999), + 'branch_id' => $branchA->id, + 'department_id' => $deptA->id, + 'created_by' => $company->id, + ]); + + // Employee in Branch B + $userB = User::create([ + 'name' => 'Employee Beta User', + 'email' => 'beta_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'employee', + 'created_by' => $company->id, + ]); + Employee::create([ + 'user_id' => $userB->id, + 'employee_id' => 'EMP-B-' . rand(100, 999), + 'branch_id' => $branchB->id, + 'department_id' => $deptB->id, + 'created_by' => $company->id, + ]); + + $hrUser = User::create([ + 'name' => 'HR Branch A User', + 'email' => 'hr_a_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'hr', + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + $hrUser->givePermissionTo('manage-departments'); + $hrUser->givePermissionTo('manage-any-departments'); + $hrUser->givePermissionTo('manage-employees'); + $hrUser->givePermissionTo('manage-any-employees'); + + // Department index check + $deptResponse = $this->actingAs($hrUser)->get(route('hr.departments.index')); + $deptResponse->assertStatus(200); + $deptResponse->assertSee('Dept Alpha Finance'); + $deptResponse->assertDontSee('Dept Beta Logistics'); + + // Employee index check + $empResponse = $this->actingAs($hrUser)->get(route('hr.employees.index')); + $empResponse->assertStatus(200); + $empResponse->assertSee('Employee Alpha User'); + $empResponse->assertDontSee('Employee Beta User'); + } + + public function test_branch_scoped_user_only_sees_assigned_branch_payslips() + { + $company = User::create([ + 'name' => 'Company Admin', + 'email' => 'company_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'company', + ]); + + $branchA = Branch::create([ + 'name' => 'Branch Alpha', + 'created_by' => $company->id, + ]); + + $branchB = Branch::create([ + 'name' => 'Branch Beta', + 'created_by' => $company->id, + ]); + + // Employee A + $userA = User::create([ + 'name' => 'Employee Alpha User', + 'email' => 'alpha_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'employee', + 'created_by' => $company->id, + ]); + Employee::create([ + 'user_id' => $userA->id, + 'employee_id' => 'EMP-A-' . rand(100, 999), + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + + // Employee B + $userB = User::create([ + 'name' => 'Employee Beta User', + 'email' => 'beta_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'employee', + 'created_by' => $company->id, + ]); + Employee::create([ + 'user_id' => $userB->id, + 'employee_id' => 'EMP-B-' . rand(100, 999), + 'branch_id' => $branchB->id, + 'created_by' => $company->id, + ]); + + $payrollRun = PayrollRun::create([ + 'title' => 'May 1-15 2026', + 'payroll_frequency' => 'monthly', + 'pay_period_start' => '2026-05-01', + 'pay_period_end' => '2026-05-15', + 'pay_date' => '2026-05-15', + 'status' => 'completed', + 'created_by' => $company->id, + ]); + + $entryA = PayrollEntry::create([ + 'payroll_run_id' => $payrollRun->id, + 'employee_id' => $userA->id, + 'basic_salary' => 10000, + 'created_by' => $company->id, + ]); + + $entryB = PayrollEntry::create([ + 'payroll_run_id' => $payrollRun->id, + 'employee_id' => $userB->id, + 'basic_salary' => 10000, + 'created_by' => $company->id, + ]); + + // Payslips + $payslipA = Payslip::create([ + 'payroll_entry_id' => $entryA->id, + 'employee_id' => $userA->id, + 'payslip_number' => 'PAY-ALPHA-001', + 'pay_period_start' => '2026-05-01', + 'pay_period_end' => '2026-05-15', + 'pay_date' => '2026-05-15', + 'status' => 'generated', + 'created_by' => $company->id, + ]); + + $payslipB = Payslip::create([ + 'payroll_entry_id' => $entryB->id, + 'employee_id' => $userB->id, + 'payslip_number' => 'PAY-BETA-002', + 'pay_period_start' => '2026-05-01', + 'pay_period_end' => '2026-05-15', + 'pay_date' => '2026-05-15', + 'status' => 'generated', + 'created_by' => $company->id, + ]); + + $hrUser = User::create([ + 'name' => 'HR Branch A User', + 'email' => 'hr_a_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'hr', + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + $hrUser->givePermissionTo('manage-payslips'); + $hrUser->givePermissionTo('manage-any-payslips'); + + $response = $this->actingAs($hrUser)->get(route('hr.payslips.index')); + $response->assertStatus(200); + $response->assertSee('PAY-ALPHA-001'); + $response->assertDontSee('PAY-BETA-002'); + } +} +