Commit f6048765 authored by Mahmoud Aglan's avatar Mahmoud Aglan

HR Update

parent 0cfaeee3
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Logger;
use App\Modules\HR\Models\HrAttendance;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Services\AttendanceService;
class AttendanceController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'department_id' => trim((string) $request->get('department_id', '')),
'date_from' => trim((string) $request->get('date_from', date('Y-m-01'))),
'date_to' => trim((string) $request->get('date_to', date('Y-m-d'))),
'status' => trim((string) $request->get('status', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrAttendance::search($filters, 50, $page);
return $this->view('HR.Views.attendance.index', [
'records' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'departments' => HrDepartment::allActive(),
]);
}
public function daily(Request $request): Response
{
$date = trim((string) $request->get('date', date('Y-m-d')));
$departmentId = (int) $request->get('department_id', 0);
$db = App::getInstance()->db();
$query = "SELECT hp.id, hp.employee_number, hp.first_name_ar, hp.last_name_ar,
d.name_ar as department_name,
a.id as attendance_id, a.check_in_time, a.check_out_time, a.status, a.actual_hours
FROM hr_employee_profiles hp
LEFT JOIN hr_departments d ON d.id = hp.department_id
LEFT JOIN hr_attendance a ON a.employee_profile_id = hp.id AND a.attendance_date = ? AND a.is_archived = 0
WHERE hp.employment_status = 'active' AND hp.is_archived = 0";
$params = [$date];
if ($departmentId > 0) {
$query .= " AND hp.department_id = ?";
$params[] = $departmentId;
}
$query .= " ORDER BY hp.first_name_ar ASC";
$employees = $db->select($query, $params);
return $this->view('HR.Views.attendance.daily', [
'employees' => $employees,
'date' => $date,
'departmentId' => $departmentId,
'departments' => HrDepartment::allActive(),
]);
}
public function storeDailyBulk(Request $request): Response
{
$date = trim((string) $request->post('date', date('Y-m-d')));
$entries = $request->post('attendance', []);
if (!is_array($entries) || empty($entries)) {
return $this->redirect('/hr/attendance/daily?date=' . $date)->withError('لا توجد بيانات حضور');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$successCount = 0;
$errorCount = 0;
foreach ($entries as $profileId => $entry) {
$checkIn = trim((string) ($entry['check_in'] ?? ''));
$checkOut = trim((string) ($entry['check_out'] ?? ''));
if ($checkIn === '' && $checkOut === '') {
continue;
}
try {
// Check existing record
$existing = $db->selectOne(
"SELECT id FROM hr_attendance WHERE employee_profile_id = ? AND attendance_date = ? AND is_archived = 0",
[(int) $profileId, $date]
);
$actualHours = 0.0;
if ($checkIn !== '' && $checkOut !== '') {
$inTs = strtotime($date . ' ' . $checkIn);
$outTs = strtotime($date . ' ' . $checkOut);
if ($outTs > $inTs) {
$actualHours = round(($outTs - $inTs) / 3600, 2);
}
}
$record = [
'employee_profile_id' => (int) $profileId,
'attendance_date' => $date,
'check_in_time' => $checkIn ?: null,
'check_out_time' => $checkOut ?: null,
'actual_hours' => (string) $actualHours,
'check_method' => 'manual',
'status' => $checkIn !== '' ? 'present' : 'absent',
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
];
if ($existing) {
$db->update('hr_attendance', $record, '`id` = ?', [(int) $existing['id']]);
} else {
$record['created_at'] = date('Y-m-d H:i:s');
$record['created_by'] = $employee ? (int) $employee->id : null;
$db->insert('hr_attendance', $record);
}
$successCount++;
} catch (\Throwable $e) {
Logger::error("Attendance entry failed for profile {$profileId}: " . $e->getMessage());
$errorCount++;
}
}
$msg = "تم تسجيل حضور {$successCount} موظف";
if ($errorCount > 0) {
$msg .= " (فشل {$errorCount})";
}
return $this->redirect('/hr/attendance/daily?date=' . $date)->withSuccess($msg);
}
public function edit(Request $request, string $id): Response
{
$attendance = HrAttendance::find((int) $id);
if (!$attendance) {
return $this->redirect('/hr/attendance')->withError('سجل الحضور غير موجود');
}
$profile = HrEmployeeProfile::find((int) $attendance->employee_profile_id);
return $this->view('HR.Views.attendance.edit', [
'attendance' => $attendance,
'profile' => $profile,
]);
}
public function update(Request $request, string $id): Response
{
$attendance = HrAttendance::find((int) $id);
if (!$attendance) {
return $this->redirect('/hr/attendance')->withError('سجل الحضور غير موجود');
}
$checkIn = trim((string) $request->post('check_in_time', ''));
$checkOut = trim((string) $request->post('check_out_time', ''));
$actualHours = 0.0;
if ($checkIn !== '' && $checkOut !== '') {
$inTs = strtotime($attendance->attendance_date . ' ' . $checkIn);
$outTs = strtotime($attendance->attendance_date . ' ' . $checkOut);
if ($outTs > $inTs) {
$actualHours = round(($outTs - $inTs) / 3600, 2);
}
}
$attendance->update([
'check_in_time' => $checkIn ?: null,
'check_out_time' => $checkOut ?: null,
'actual_hours' => (string) $actualHours,
'overtime_hours' => trim((string) $request->post('overtime_hours', '0')),
'late_minutes' => (int) $request->post('late_minutes', 0),
'early_leave_minutes' => (int) $request->post('early_leave_minutes', 0),
'status' => trim((string) $request->post('status', 'present')),
'notes' => trim((string) $request->post('notes', '')) ?: null,
]);
return $this->redirect('/hr/attendance')->withSuccess('تم تحديث سجل الحضور بنجاح');
}
public function approveBulk(Request $request): Response
{
$ids = $request->post('ids', []);
if (!is_array($ids) || empty($ids)) {
return $this->redirect('/hr/attendance')->withError('لم يتم تحديد سجلات');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$count = 0;
foreach ($ids as $id) {
$db->update('hr_attendance', [
'is_approved' => 1,
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND `is_approved` = 0', [(int) $id]);
$count++;
}
return $this->redirect('/hr/attendance')->withSuccess("تم اعتماد {$count} سجل حضور");
}
public function monthly(Request $request, string $employeeId): Response
{
$profile = HrEmployeeProfile::find((int) $employeeId);
if (!$profile) {
return $this->redirect('/hr/attendance')->withError('الموظف غير موجود');
}
$year = (int) $request->get('year', (int) date('Y'));
$month = (int) $request->get('month', (int) date('m'));
$records = HrAttendance::getMonthly((int) $employeeId, $year, $month);
$summary = HrAttendance::getMonthlySummary((int) $employeeId, $year, $month);
return $this->view('HR.Views.attendance.monthly', [
'profile' => $profile,
'records' => $records,
'summary' => $summary,
'year' => $year,
'month' => $month,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Logger;
use App\Modules\HR\Models\HrContract;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\ContractService;
class ContractController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'status' => trim((string) $request->get('status', '')),
'type' => trim((string) $request->get('type', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrContract::search($filters, 25, $page);
return $this->view('HR.Views.contracts.index', [
'contracts' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
$employeeId = (int) $request->get('employee_id', 0);
$profile = $employeeId ? HrEmployeeProfile::find($employeeId) : null;
return $this->view('HR.Views.contracts.form', [
'contract' => null,
'profile' => $profile,
]);
}
public function store(Request $request): Response
{
$data = [
'employee_profile_id' => (int) $request->post('employee_profile_id', 0),
'contract_type' => trim((string) $request->post('contract_type', 'definite')),
'start_date' => trim((string) $request->post('start_date', '')),
'end_date' => trim((string) $request->post('end_date', '')) ?: null,
'probation_months' => (int) $request->post('probation_months', 0),
'working_hours_per_day' => trim((string) $request->post('working_hours_per_day', '8')),
'notice_period_months' => (int) $request->post('notice_period_months', 2),
'basic_salary' => trim((string) $request->post('basic_salary', '0.00')),
'terms_ar' => trim((string) $request->post('terms_ar', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$result = ContractService::create($data);
if (!$result['success']) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/contracts/create');
}
return $this->redirect('/hr/contracts/' . $result['contract_id'])->withSuccess('تم إنشاء العقد بنجاح');
}
public function show(Request $request, string $id): Response
{
$contract = HrContract::find((int) $id);
if (!$contract) {
return $this->redirect('/hr/contracts')->withError('العقد غير موجود');
}
$profile = HrEmployeeProfile::find((int) $contract->employee_profile_id);
$db = App::getInstance()->db();
// Previous contract in renewal chain
$previousContract = $contract->previous_contract_id
? HrContract::find((int) $contract->previous_contract_id)
: null;
// Renewal history
$renewals = $db->select(
"SELECT id, contract_number, start_date, end_date, status
FROM hr_contracts
WHERE previous_contract_id = ? AND is_archived = 0
ORDER BY start_date ASC",
[(int) $id]
);
return $this->view('HR.Views.contracts.show', [
'contract' => $contract,
'profile' => $profile,
'previousContract' => $previousContract,
'renewals' => $renewals,
]);
}
public function edit(Request $request, string $id): Response
{
$contract = HrContract::find((int) $id);
if (!$contract) {
return $this->redirect('/hr/contracts')->withError('العقد غير موجود');
}
if ($contract->status !== 'draft') {
return $this->redirect('/hr/contracts/' . $id)->withError('لا يمكن تعديل عقد غير مسودة');
}
$profile = HrEmployeeProfile::find((int) $contract->employee_profile_id);
return $this->view('HR.Views.contracts.form', [
'contract' => $contract,
'profile' => $profile,
]);
}
public function update(Request $request, string $id): Response
{
$contract = HrContract::find((int) $id);
if (!$contract) {
return $this->redirect('/hr/contracts')->withError('العقد غير موجود');
}
if ($contract->status !== 'draft') {
return $this->redirect('/hr/contracts/' . $id)->withError('لا يمكن تعديل عقد غير مسودة');
}
$data = [
'contract_type' => trim((string) $request->post('contract_type', 'definite')),
'start_date' => trim((string) $request->post('start_date', '')),
'end_date' => trim((string) $request->post('end_date', '')) ?: null,
'probation_months' => (int) $request->post('probation_months', 0),
'working_hours_per_day' => trim((string) $request->post('working_hours_per_day', '8')),
'notice_period_months' => (int) $request->post('notice_period_months', 2),
'basic_salary' => trim((string) $request->post('basic_salary', '0.00')),
'terms_ar' => trim((string) $request->post('terms_ar', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$errors = [];
if ($data['start_date'] === '') {
$errors[] = 'تاريخ بداية العقد مطلوب';
}
if ($data['contract_type'] === 'definite' && empty($data['end_date'])) {
$errors[] = 'تاريخ نهاية العقد مطلوب للعقد المحدد المدة';
}
if ($data['probation_months'] > 3) {
$errors[] = 'فترة الاختبار لا تتجاوز 3 أشهر (مادة 33)';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/contracts/' . $id . '/edit');
}
$contract->update($data);
return $this->redirect('/hr/contracts/' . $id)->withSuccess('تم تحديث العقد بنجاح');
}
public function renew(Request $request, string $id): Response
{
$contract = HrContract::find((int) $id);
if (!$contract) {
return $this->redirect('/hr/contracts')->withError('العقد غير موجود');
}
$newStartDate = trim((string) $request->post('new_start_date', ''));
$newEndDate = trim((string) $request->post('new_end_date', '')) ?: null;
if ($newStartDate === '') {
return $this->redirect('/hr/contracts/' . $id)->withError('تاريخ بداية العقد الجديد مطلوب');
}
$result = ContractService::renew((int) $id, $newStartDate, $newEndDate);
if (!$result['success']) {
return $this->redirect('/hr/contracts/' . $id)->withError($result['error']);
}
return $this->redirect('/hr/contracts/' . $result['contract_id'])->withSuccess('تم تجديد العقد بنجاح');
}
public function terminate(Request $request, string $id): Response
{
$contract = HrContract::find((int) $id);
if (!$contract) {
return $this->redirect('/hr/contracts')->withError('العقد غير موجود');
}
$terminationDate = trim((string) $request->post('termination_date', date('Y-m-d')));
$terminationReason = trim((string) $request->post('termination_reason', ''));
if ($terminationReason === '') {
return $this->redirect('/hr/contracts/' . $id)->withError('سبب إنهاء العقد مطلوب');
}
$result = ContractService::terminate((int) $id, $terminationDate, $terminationReason);
if (!$result['success']) {
return $this->redirect('/hr/contracts/' . $id)->withError($result['error']);
}
return $this->redirect('/hr/contracts/' . $id)->withSuccess('تم إنهاء العقد بنجاح');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Models\HrEmployeeProfile;
class DepartmentController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'branch_id' => trim((string) $request->get('branch_id', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrDepartment::search($filters, 25, $page);
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('HR.Views.departments.index', [
'departments' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'branches' => $branches,
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$departments = HrDepartment::allActive();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
$employees = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.departments.form', [
'department' => null,
'departments' => $departments,
'branches' => $branches,
'employees' => $employees,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/departments/create');
}
$department = HrDepartment::create($data);
return $this->redirect('/hr/departments/' . $department->id)->withSuccess('تم إنشاء القسم بنجاح');
}
public function show(Request $request, string $id): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$db = App::getInstance()->db();
$parent = $department->parent_id ? HrDepartment::find((int) $department->parent_id) : null;
$manager = $department->manager_id
? $db->selectOne("SELECT hp.id, hp.first_name_ar, hp.last_name_ar FROM hr_employee_profiles hp WHERE hp.id = ? AND hp.is_archived = 0", [(int) $department->manager_id])
: null;
$branch = $department->branch_id
? $db->selectOne("SELECT id, name_ar FROM branches WHERE id = ?", [(int) $department->branch_id])
: null;
$children = $db->select(
"SELECT id, name_ar, code FROM hr_departments WHERE parent_id = ? AND is_archived = 0 ORDER BY name_ar",
[(int) $id]
);
$employeeCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_profiles WHERE department_id = ? AND employment_status = 'active' AND is_archived = 0",
[(int) $id]
);
return $this->view('HR.Views.departments.show', [
'department' => $department,
'parent' => $parent,
'manager' => $manager,
'branch' => $branch,
'children' => $children,
'employeeCount' => (int) ($employeeCount['cnt'] ?? 0),
]);
}
public function edit(Request $request, string $id): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$db = App::getInstance()->db();
$departments = HrDepartment::allActive();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
$employees = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.departments.form', [
'department' => $department,
'departments' => $departments,
'branches' => $branches,
'employees' => $employees,
]);
}
public function update(Request $request, string $id): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validateInput($data);
// Prevent circular parent
if ((int) ($data['parent_id'] ?? 0) === (int) $id) {
$errors[] = 'لا يمكن أن يكون القسم هو الأب لنفسه';
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/departments/' . $id . '/edit');
}
$department->update($data);
return $this->redirect('/hr/departments/' . $id)->withSuccess('تم تحديث القسم بنجاح');
}
public function archive(Request $request, string $id): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$db = App::getInstance()->db();
$employeeCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_profiles WHERE department_id = ? AND employment_status = 'active' AND is_archived = 0",
[(int) $id]
);
if ((int) ($employeeCount['cnt'] ?? 0) > 0) {
return $this->redirect('/hr/departments/' . $id)->withError('لا يمكن حذف قسم يحتوي على موظفين نشطين');
}
$childCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_departments WHERE parent_id = ? AND is_archived = 0",
[(int) $id]
);
if ((int) ($childCount['cnt'] ?? 0) > 0) {
return $this->redirect('/hr/departments/' . $id)->withError('لا يمكن حذف قسم يحتوي على أقسام فرعية');
}
$department->archive();
return $this->redirect('/hr/departments')->withSuccess('تم حذف القسم بنجاح');
}
private function extractData(Request $request): array
{
return [
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'code' => strtoupper(trim((string) $request->post('code', ''))),
'parent_id' => ((int) $request->post('parent_id', 0)) ?: null,
'manager_id' => ((int) $request->post('manager_id', 0)) ?: null,
'branch_id' => ((int) $request->post('branch_id', 0)) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validateInput(array $data): array
{
$errors = [];
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم القسم بالعربي مطلوب (حرفان على الأقل)';
}
if ($data['code'] === '') {
$errors[] = 'كود القسم مطلوب';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Logger;
use App\Modules\HR\Models\HrEmployeeDocument;
use App\Modules\HR\Models\HrEmployeeProfile;
class EmployeeDocumentController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'document_type' => trim((string) $request->get('document_type', '')),
'expiring_soon' => trim((string) $request->get('expiring_soon', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrEmployeeDocument::search($filters, 25, $page);
return $this->view('HR.Views.documents.index', [
'documents' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'documentTypes' => HrEmployeeDocument::getDocumentTypes(),
]);
}
public function byEmployee(Request $request, string $employeeId): Response
{
$profile = HrEmployeeProfile::find((int) $employeeId);
if (!$profile) {
return $this->redirect('/hr/documents')->withError('الموظف غير موجود');
}
$db = App::getInstance()->db();
$documents = $db->select(
"SELECT * FROM hr_employee_documents WHERE employee_profile_id = ? AND is_archived = 0 ORDER BY created_at DESC",
[(int) $employeeId]
);
return $this->view('HR.Views.documents.by_employee', [
'profile' => $profile,
'documents' => $documents,
'documentTypes' => HrEmployeeDocument::getDocumentTypes(),
]);
}
public function upload(Request $request): Response
{
$profileId = (int) $request->post('employee_profile_id', 0);
$documentType = trim((string) $request->post('document_type', ''));
$documentNumber = trim((string) $request->post('document_number', '')) ?: null;
$issueDate = trim((string) $request->post('issue_date', '')) ?: null;
$expiryDate = trim((string) $request->post('expiry_date', '')) ?: null;
$notes = trim((string) $request->post('notes', '')) ?: null;
$errors = [];
if ($profileId <= 0) {
$errors[] = 'الموظف غير محدد';
}
if ($documentType === '') {
$errors[] = 'نوع المستند مطلوب';
}
// Handle file upload
$filePath = null;
if (isset($_FILES['document_file']) && $_FILES['document_file']['error'] === UPLOAD_ERR_OK) {
$allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
$fileType = $_FILES['document_file']['type'];
$fileSize = $_FILES['document_file']['size'];
if (!in_array($fileType, $allowedTypes, true)) {
$errors[] = 'نوع الملف غير مسموح. المسموح: PDF, JPEG, PNG';
}
if ($fileSize > 5 * 1024 * 1024) {
$errors[] = 'حجم الملف يجب أن يكون أقل من 5 ميجابايت';
}
if (empty($errors)) {
$uploadDir = 'uploads/hr/documents/' . $profileId;
$absUploadDir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/' . $uploadDir;
if (!is_dir($absUploadDir)) {
mkdir($absUploadDir, 0755, true);
}
$ext = pathinfo($_FILES['document_file']['name'], PATHINFO_EXTENSION);
$fileName = $documentType . '_' . date('Ymd_His') . '.' . $ext;
$targetPath = $absUploadDir . '/' . $fileName;
if (move_uploaded_file($_FILES['document_file']['tmp_name'], $targetPath)) {
$filePath = $uploadDir . '/' . $fileName;
} else {
$errors[] = 'فشل رفع الملف';
}
}
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/documents/employee/' . $profileId);
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$docId = $db->insert('hr_employee_documents', [
'employee_profile_id' => $profileId,
'document_type' => $documentType,
'document_number' => $documentNumber,
'issue_date' => $issueDate,
'expiry_date' => $expiryDate,
'file_path' => $filePath,
'notes' => $notes,
'is_verified' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
Logger::info("Document uploaded", ['document_id' => $docId, 'profile_id' => $profileId, 'type' => $documentType]);
return $this->redirect('/hr/documents/employee/' . $profileId)->withSuccess('تم رفع المستند بنجاح');
}
public function verify(Request $request, string $id): Response
{
$document = HrEmployeeDocument::find((int) $id);
if (!$document) {
return $this->redirect('/hr/documents')->withError('المستند غير موجود');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$db->update('hr_employee_documents', [
'is_verified' => 1,
'verified_by' => $employee ? (int) $employee->id : null,
'verified_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/hr/documents/employee/' . $document->employee_profile_id)->withSuccess('تم التحقق من المستند');
}
public function archive(Request $request, string $id): Response
{
$document = HrEmployeeDocument::find((int) $id);
if (!$document) {
return $this->redirect('/hr/documents')->withError('المستند غير موجود');
}
$profileId = $document->employee_profile_id;
$document->archive();
return $this->redirect('/hr/documents/employee/' . $profileId)->withSuccess('تم حذف المستند');
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Logger;
use App\Modules\HR\Models\HrEndOfService;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\EndOfServiceService;
class EndOfServiceController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'status' => trim((string) $request->get('status', '')),
'type' => trim((string) $request->get('type', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrEndOfService::search($filters, 25, $page);
return $this->view('HR.Views.end_of_service.index', [
'records' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
$employees = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.end_of_service.form', [
'record' => null,
'employees' => $employees,
]);
}
public function store(Request $request): Response
{
$profileId = (int) $request->post('employee_profile_id', 0);
$terminationType = trim((string) $request->post('termination_type', ''));
$terminationDate = trim((string) $request->post('termination_date', date('Y-m-d')));
$terminationReason = trim((string) $request->post('termination_reason', ''));
$errors = [];
if ($profileId <= 0) {
$errors[] = 'الموظف غير محدد';
}
if ($terminationType === '') {
$errors[] = 'نوع إنهاء الخدمة مطلوب';
}
if ($terminationDate === '') {
$errors[] = 'تاريخ إنهاء الخدمة مطلوب';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/end-of-service/create');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$recordId = $db->insert('hr_end_of_service', [
'employee_profile_id' => $profileId,
'termination_type' => $terminationType,
'termination_date' => $terminationDate,
'termination_reason' => $terminationReason ?: null,
'status' => 'draft',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
return $this->redirect('/hr/end-of-service/' . $recordId)->withSuccess('تم إنشاء سجل نهاية الخدمة بنجاح');
}
public function show(Request $request, string $id): Response
{
$record = HrEndOfService::find((int) $id);
if (!$record) {
return $this->redirect('/hr/end-of-service')->withError('السجل غير موجود');
}
$profile = HrEmployeeProfile::find((int) $record->employee_profile_id);
$db = App::getInstance()->db();
$approver = $record->approved_by
? $db->selectOne("SELECT name FROM employees WHERE id = ?", [(int) $record->approved_by])
: null;
return $this->view('HR.Views.end_of_service.show', [
'record' => $record,
'profile' => $profile,
'approver' => $approver,
]);
}
public function calculate(Request $request, string $id): Response
{
$record = HrEndOfService::find((int) $id);
if (!$record) {
return $this->redirect('/hr/end-of-service')->withError('السجل غير موجود');
}
if (!in_array($record->status, ['draft', 'calculated'], true)) {
return $this->redirect('/hr/end-of-service/' . $id)->withError('لا يمكن إعادة الحساب في هذه الحالة');
}
$result = EndOfServiceService::calculate(
(int) $record->employee_profile_id,
$record->termination_date,
$record->termination_type
);
if (!$result['success']) {
return $this->redirect('/hr/end-of-service/' . $id)->withError($result['error']);
}
$db = App::getInstance()->db();
$db->update('hr_end_of_service', [
'years_of_service' => $result['years_of_service'],
'last_basic_salary' => $result['last_basic_salary'],
'severance_amount' => $result['severance_amount'],
'notice_compensation' => $result['notice_compensation'],
'leave_compensation' => $result['leave_compensation'],
'other_compensation' => '0.00',
'total_entitlements' => $result['total_entitlements'],
'outstanding_loans' => $result['outstanding_loans'],
'other_deductions' => '0.00',
'net_settlement' => $result['net_settlement'],
'calculation_json' => json_encode($result['breakdown'], JSON_UNESCAPED_UNICODE),
'status' => 'calculated',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
Logger::info("End of service calculated", ['record_id' => (int) $id, 'net_settlement' => $result['net_settlement']]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess(
'تم حساب مستحقات نهاية الخدمة: ' . number_format((float) $result['net_settlement'], 2) . ' ج.م'
);
}
public function approve(Request $request, string $id): Response
{
$record = HrEndOfService::find((int) $id);
if (!$record) {
return $this->redirect('/hr/end-of-service')->withError('السجل غير موجود');
}
if ($record->status !== 'calculated') {
return $this->redirect('/hr/end-of-service/' . $id)->withError('يجب حساب المستحقات أولاً');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$db->update('hr_end_of_service', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
// Update employee status to terminated
$db->update('hr_employee_profiles', [
'employment_status' => 'terminated',
'termination_date' => $record->termination_date,
'termination_type' => $record->termination_type,
'termination_reason' => $record->termination_reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND `is_archived` = 0', [(int) $record->employee_profile_id]);
Logger::info("End of service approved", ['record_id' => (int) $id]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم اعتماد نهاية الخدمة');
}
public function markPaid(Request $request, string $id): Response
{
$record = HrEndOfService::find((int) $id);
if (!$record) {
return $this->redirect('/hr/end-of-service')->withError('السجل غير موجود');
}
if ($record->status !== 'approved') {
return $this->redirect('/hr/end-of-service/' . $id)->withError('يجب اعتماد نهاية الخدمة أولاً');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$paymentDate = trim((string) $request->post('payment_date', date('Y-m-d')));
$db->update('hr_end_of_service', [
'status' => 'paid',
'payment_date' => $paymentDate,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
Logger::info("End of service paid", ['record_id' => (int) $id, 'payment_date' => $paymentDate]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم صرف مستحقات نهاية الخدمة');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrHoliday;
class HolidayController extends Controller
{
public function index(Request $request): Response
{
$year = (int) $request->get('year', (int) date('Y'));
$holidays = HrHoliday::getForYear($year);
return $this->view('HR.Views.holidays.index', [
'holidays' => $holidays,
'year' => $year,
]);
}
public function create(Request $request): Response
{
return $this->view('HR.Views.holidays.form', [
'holiday' => null,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/holidays/create');
}
HrHoliday::create($data);
return $this->redirect('/hr/holidays')->withSuccess('تم إنشاء العطلة بنجاح');
}
public function edit(Request $request, string $id): Response
{
$holiday = HrHoliday::find((int) $id);
if (!$holiday) {
return $this->redirect('/hr/holidays')->withError('العطلة غير موجودة');
}
return $this->view('HR.Views.holidays.form', [
'holiday' => $holiday,
]);
}
public function update(Request $request, string $id): Response
{
$holiday = HrHoliday::find((int) $id);
if (!$holiday) {
return $this->redirect('/hr/holidays')->withError('العطلة غير موجودة');
}
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/holidays/' . $id . '/edit');
}
$holiday->update($data);
return $this->redirect('/hr/holidays')->withSuccess('تم تحديث العطلة بنجاح');
}
public function archive(Request $request, string $id): Response
{
$holiday = HrHoliday::find((int) $id);
if (!$holiday) {
return $this->redirect('/hr/holidays')->withError('العطلة غير موجودة');
}
$holiday->archive();
return $this->redirect('/hr/holidays')->withSuccess('تم حذف العطلة بنجاح');
}
private function extractData(Request $request): array
{
return [
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'holiday_date' => trim((string) $request->post('holiday_date', '')),
'duration_days' => max(1, (int) $request->post('duration_days', 1)),
'holiday_type' => trim((string) $request->post('holiday_type', 'national')),
'is_recurring' => (int) ($request->post('is_recurring', 0)),
'religion' => trim((string) $request->post('religion', '')) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validateInput(array $data): array
{
$errors = [];
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم العطلة بالعربي مطلوب';
}
if ($data['holiday_date'] === '') {
$errors[] = 'تاريخ العطلة مطلوب';
}
if (!in_array($data['holiday_type'], ['national', 'religious', 'company'], true)) {
$errors[] = 'نوع العطلة غير صالح';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrInsuranceRecord;
use App\Modules\HR\Models\HrPayrollPeriod;
use App\Modules\HR\Models\HrEmployeeProfile;
class InsuranceController extends Controller
{
public function index(Request $request): Response
{
$year = (int) $request->get('year', (int) date('Y'));
$db = App::getInstance()->db();
$periods = $db->select(
"SELECT pp.id, pp.period_code, pp.year, pp.month, pp.status,
(SELECT COUNT(*) FROM hr_insurance_records ir WHERE ir.period_id = pp.id AND ir.is_archived = 0) as record_count,
(SELECT SUM(employee_share_total) FROM hr_insurance_records ir WHERE ir.period_id = pp.id AND ir.is_archived = 0) as total_employee_share,
(SELECT SUM(employer_share_total) FROM hr_insurance_records ir WHERE ir.period_id = pp.id AND ir.is_archived = 0) as total_employer_share
FROM hr_payroll_periods pp
WHERE pp.year = ? AND pp.is_archived = 0
ORDER BY pp.month ASC",
[$year]
);
return $this->view('HR.Views.insurance.index', [
'periods' => $periods,
'year' => $year,
]);
}
public function periodRecords(Request $request, string $periodId): Response
{
$period = HrPayrollPeriod::find((int) $periodId);
if (!$period) {
return $this->redirect('/hr/insurance')->withError('فترة الرواتب غير موجودة');
}
$records = HrInsuranceRecord::getForPeriod((int) $periodId);
$totals = HrInsuranceRecord::getPeriodTotals((int) $periodId);
return $this->view('HR.Views.insurance.period_records', [
'period' => $period,
'records' => $records,
'totals' => $totals,
]);
}
public function employeeHistory(Request $request, string $employeeId): Response
{
$profile = HrEmployeeProfile::find((int) $employeeId);
if (!$profile) {
return $this->redirect('/hr/insurance')->withError('الموظف غير موجود');
}
$year = (int) $request->get('year', (int) date('Y'));
$records = HrInsuranceRecord::getForEmployee((int) $employeeId, $year);
return $this->view('HR.Views.insurance.employee_history', [
'profile' => $profile,
'records' => $records,
'year' => $year,
]);
}
public function form1(Request $request): Response
{
$year = (int) $request->get('year', (int) date('Y'));
$month = (int) $request->get('month', (int) date('m'));
$db = App::getInstance()->db();
// Form 1: New employee registration form
// Get employees hired in the specified month
$newEmployees = $db->select(
"SELECT hp.*, d.name_ar as department_name, jt.name_ar as job_title_name
FROM hr_employee_profiles hp
LEFT JOIN hr_departments d ON d.id = hp.department_id
LEFT JOIN hr_job_titles jt ON jt.id = hp.job_title_id
WHERE YEAR(hp.hire_date) = ? AND MONTH(hp.hire_date) = ?
AND hp.insurance_number IS NOT NULL
AND hp.is_archived = 0
ORDER BY hp.hire_date ASC",
[$year, $month]
);
return $this->view('HR.Views.insurance.form1', [
'employees' => $newEmployees,
'year' => $year,
'month' => $month,
]);
}
public function form6(Request $request): Response
{
$year = (int) $request->get('year', (int) date('Y'));
$month = (int) $request->get('month', (int) date('m'));
$db = App::getInstance()->db();
// Form 6: Monthly insurance contributions
$periodId = $db->selectOne(
"SELECT id FROM hr_payroll_periods WHERE year = ? AND month = ? AND is_archived = 0",
[$year, $month]
);
$records = [];
$totals = null;
if ($periodId) {
$records = $db->select(
"SELECT ir.*, hp.employee_number, hp.first_name_ar, hp.last_name_ar,
hp.national_id, hp.insurance_number,
d.name_ar as department_name
FROM hr_insurance_records ir
JOIN hr_employee_profiles hp ON hp.id = ir.employee_profile_id
LEFT JOIN hr_departments d ON d.id = hp.department_id
WHERE ir.period_id = ? AND ir.is_archived = 0
ORDER BY hp.first_name_ar ASC",
[(int) $periodId['id']]
);
$totals = HrInsuranceRecord::getPeriodTotals((int) $periodId['id']);
}
return $this->view('HR.Views.insurance.form6', [
'records' => $records,
'totals' => $totals,
'year' => $year,
'month' => $month,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrJobTitle;
class JobTitleController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'grade_level' => trim((string) $request->get('grade_level', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrJobTitle::search($filters, 25, $page);
return $this->view('HR.Views.job_titles.index', [
'jobTitles' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
return $this->view('HR.Views.job_titles.form', [
'jobTitle' => null,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/job-titles/create');
}
$jobTitle = HrJobTitle::create($data);
return $this->redirect('/hr/job-titles')->withSuccess('تم إنشاء المسمى الوظيفي بنجاح');
}
public function edit(Request $request, string $id): Response
{
$jobTitle = HrJobTitle::find((int) $id);
if (!$jobTitle) {
return $this->redirect('/hr/job-titles')->withError('المسمى الوظيفي غير موجود');
}
return $this->view('HR.Views.job_titles.form', [
'jobTitle' => $jobTitle,
]);
}
public function update(Request $request, string $id): Response
{
$jobTitle = HrJobTitle::find((int) $id);
if (!$jobTitle) {
return $this->redirect('/hr/job-titles')->withError('المسمى الوظيفي غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/job-titles/' . $id . '/edit');
}
$jobTitle->update($data);
return $this->redirect('/hr/job-titles')->withSuccess('تم تحديث المسمى الوظيفي بنجاح');
}
public function archive(Request $request, string $id): Response
{
$jobTitle = HrJobTitle::find((int) $id);
if (!$jobTitle) {
return $this->redirect('/hr/job-titles')->withError('المسمى الوظيفي غير موجود');
}
$db = App::getInstance()->db();
$count = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_profiles WHERE job_title_id = ? AND employment_status = 'active' AND is_archived = 0",
[(int) $id]
);
if ((int) ($count['cnt'] ?? 0) > 0) {
return $this->redirect('/hr/job-titles')->withError('لا يمكن حذف مسمى وظيفي مرتبط بموظفين نشطين');
}
$jobTitle->archive();
return $this->redirect('/hr/job-titles')->withSuccess('تم حذف المسمى الوظيفي بنجاح');
}
private function extractData(Request $request): array
{
return [
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'code' => strtoupper(trim((string) $request->post('code', ''))),
'grade_level' => ((int) $request->post('grade_level', 0)) ?: null,
'min_salary' => trim((string) $request->post('min_salary', '')) ?: null,
'max_salary' => trim((string) $request->post('max_salary', '')) ?: null,
'description' => trim((string) $request->post('description', '')) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validateInput(array $data): array
{
$errors = [];
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم المسمى الوظيفي بالعربي مطلوب (حرفان على الأقل)';
}
if ($data['code'] === '') {
$errors[] = 'كود المسمى الوظيفي مطلوب';
}
if ($data['min_salary'] !== null && $data['max_salary'] !== null) {
if (bccomp($data['min_salary'], $data['max_salary'], 2) > 0) {
$errors[] = 'الحد الأدنى للراتب يجب أن يكون أقل من أو يساوي الحد الأقصى';
}
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrEmployeeLoan;
use App\Modules\HR\Models\HrLoanInstallment;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\LoanService;
class LoanController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'status' => trim((string) $request->get('status', '')),
'type' => trim((string) $request->get('type', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = HrEmployeeLoan::search($filters, 25, $page);
return $this->view('HR.Views.loans.index', [
'loans' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
$employees = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.loans.form', [
'loan' => null,
'employees' => $employees,
]);
}
public function store(Request $request): Response
{
$data = [
'employee_profile_id' => (int) $request->post('employee_profile_id', 0),
'loan_type' => trim((string) $request->post('loan_type', 'salary_advance')),
'loan_amount' => trim((string) $request->post('loan_amount', '0.00')),
'number_of_installments' => (int) $request->post('number_of_installments', 1),
'request_date' => trim((string) $request->post('request_date', date('Y-m-d'))),
'start_deduction_date' => trim((string) $request->post('start_deduction_date', '')),
'reason' => trim((string) $request->post('reason', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$result = LoanService::create($data);
if (!$result['success']) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/loans/create');
}
return $this->redirect('/hr/loans/' . $result['loan_id'])->withSuccess('تم إنشاء طلب السلفة بنجاح');
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$loan = $db->selectOne(
"SELECT l.*, hp.first_name_ar, hp.last_name_ar, hp.employee_number
FROM hr_employee_loans l
JOIN hr_employee_profiles hp ON hp.id = l.employee_profile_id
WHERE l.id = ? AND l.is_archived = 0",
[(int) $id]
);
if (!$loan) {
return $this->redirect('/hr/loans')->withError('السلفة غير موجودة');
}
$installments = HrLoanInstallment::getForLoan((int) $id);
$approver = $loan['approved_by']
? $db->selectOne("SELECT name FROM employees WHERE id = ?", [(int) $loan['approved_by']])
: null;
return $this->view('HR.Views.loans.show', [
'loan' => $loan,
'installments' => $installments,
'approver' => $approver,
]);
}
public function approve(Request $request, string $id): Response
{
$result = LoanService::approve((int) $id);
if (!$result['success']) {
return $this->redirect('/hr/loans/' . $id)->withError($result['error']);
}
return $this->redirect('/hr/loans/' . $id)->withSuccess('تم اعتماد السلفة بنجاح');
}
public function reject(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$loan = $db->selectOne("SELECT * FROM hr_employee_loans WHERE id = ? AND is_archived = 0", [(int) $id]);
if (!$loan || $loan['status'] !== 'pending') {
return $this->redirect('/hr/loans/' . $id)->withError('السلفة غير قابلة للرفض');
}
$rejectionReason = trim((string) $request->post('rejection_reason', ''));
$db->update('hr_employee_loans', [
'status' => 'rejected',
'notes' => $rejectionReason ? ('رفض: ' . $rejectionReason) : $loan['notes'],
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
return $this->redirect('/hr/loans/' . $id)->withSuccess('تم رفض السلفة');
}
public function disburse(Request $request, string $id): Response
{
$result = LoanService::disburse((int) $id);
if (!$result['success']) {
return $this->redirect('/hr/loans/' . $id)->withError($result['error']);
}
return $this->redirect('/hr/loans/' . $id)->withSuccess('تم صرف السلفة بنجاح');
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrPerformanceCycle;
use App\Modules\HR\Models\HrPerformanceReview;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Models\HrDepartment;
class PerformanceController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$activeCycle = HrPerformanceCycle::getActiveCycle();
$recentCycles = $db->select(
"SELECT * FROM hr_performance_cycles WHERE is_archived = 0 ORDER BY start_date DESC LIMIT 10"
);
return $this->view('HR.Views.performance.index', [
'activeCycle' => $activeCycle,
'recentCycles' => $recentCycles,
]);
}
public function cycles(Request $request): Response
{
$db = App::getInstance()->db();
$cycles = $db->select(
"SELECT pc.*,
(SELECT COUNT(*) FROM hr_performance_reviews pr WHERE pr.cycle_id = pc.id AND pr.is_archived = 0) as review_count,
(SELECT AVG(pr.overall_rating) FROM hr_performance_reviews pr WHERE pr.cycle_id = pc.id AND pr.is_archived = 0 AND pr.overall_rating > 0) as avg_rating
FROM hr_performance_cycles pc
WHERE pc.is_archived = 0
ORDER BY pc.start_date DESC"
);
return $this->view('HR.Views.performance.cycles', [
'cycles' => $cycles,
]);
}
public function createCycle(Request $request): Response
{
$name = trim((string) $request->post('name_ar', ''));
$cycleType = trim((string) $request->post('cycle_type', 'annual'));
$startDate = trim((string) $request->post('start_date', ''));
$endDate = trim((string) $request->post('end_date', ''));
$errors = [];
if ($name === '') {
$errors[] = 'اسم دورة التقييم مطلوب';
}
if ($startDate === '' || $endDate === '') {
$errors[] = 'تاريخ البداية والنهاية مطلوبان';
}
if ($startDate !== '' && $endDate !== '' && $startDate >= $endDate) {
$errors[] = 'تاريخ البداية يجب أن يكون قبل تاريخ النهاية';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/performance/cycles');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$cycleId = $db->insert('hr_performance_cycles', [
'name_ar' => $name,
'cycle_type' => $cycleType,
'start_date' => $startDate,
'end_date' => $endDate,
'status' => 'draft',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
// Auto-create review entries for all active employees
$activeEmployees = $db->select(
"SELECT id FROM hr_employee_profiles WHERE employment_status = 'active' AND is_archived = 0"
);
foreach ($activeEmployees as $emp) {
$db->insert('hr_performance_reviews', [
'cycle_id' => $cycleId,
'employee_profile_id' => (int) $emp['id'],
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return $this->redirect('/hr/performance/cycles/' . $cycleId)->withSuccess(
'تم إنشاء دورة التقييم بنجاح وإضافة ' . count($activeEmployees) . ' موظف'
);
}
public function showCycle(Request $request, string $id): Response
{
$cycle = HrPerformanceCycle::find((int) $id);
if (!$cycle) {
return $this->redirect('/hr/performance/cycles')->withError('دورة التقييم غير موجودة');
}
$reviews = HrPerformanceCycle::getReviewsForCycle((int) $id);
$db = App::getInstance()->db();
$stats = $db->selectOne(
"SELECT COUNT(*) as total,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
AVG(CASE WHEN overall_rating > 0 THEN overall_rating ELSE NULL END) as avg_rating
FROM hr_performance_reviews
WHERE cycle_id = ? AND is_archived = 0",
[(int) $id]
);
return $this->view('HR.Views.performance.show_cycle', [
'cycle' => $cycle,
'reviews' => $reviews,
'stats' => $stats,
]);
}
public function showReview(Request $request, string $id): Response
{
$review = HrPerformanceReview::find((int) $id);
if (!$review) {
return $this->redirect('/hr/performance')->withError('تقييم الأداء غير موجود');
}
$profile = HrEmployeeProfile::find((int) $review->employee_profile_id);
$cycle = HrPerformanceCycle::find((int) $review->cycle_id);
$db = App::getInstance()->db();
$department = $profile->department_id
? $db->selectOne("SELECT name_ar FROM hr_departments WHERE id = ?", [(int) $profile->department_id])
: null;
$reviewer = $review->reviewer_id
? $db->selectOne("SELECT name FROM employees WHERE id = ?", [(int) $review->reviewer_id])
: null;
return $this->view('HR.Views.performance.show_review', [
'review' => $review,
'profile' => $profile,
'cycle' => $cycle,
'department' => $department,
'reviewer' => $reviewer,
]);
}
public function editReview(Request $request, string $id): Response
{
$review = HrPerformanceReview::find((int) $id);
if (!$review) {
return $this->redirect('/hr/performance')->withError('تقييم الأداء غير موجود');
}
if ($review->status === 'acknowledged') {
return $this->redirect('/hr/performance/reviews/' . $id)->withError('لا يمكن تعديل تقييم تم الإقرار به');
}
$profile = HrEmployeeProfile::find((int) $review->employee_profile_id);
$cycle = HrPerformanceCycle::find((int) $review->cycle_id);
return $this->view('HR.Views.performance.review_form', [
'review' => $review,
'profile' => $profile,
'cycle' => $cycle,
]);
}
public function updateReview(Request $request, string $id): Response
{
$review = HrPerformanceReview::find((int) $id);
if (!$review) {
return $this->redirect('/hr/performance')->withError('تقييم الأداء غير موجود');
}
if ($review->status === 'acknowledged') {
return $this->redirect('/hr/performance/reviews/' . $id)->withError('لا يمكن تعديل تقييم تم الإقرار به');
}
$employee = $this->currentEmployee();
$scoresJson = $request->post('scores', []);
$overallRating = (float) $request->post('overall_rating', 0);
$strengths = trim((string) $request->post('strengths', '')) ?: null;
$improvements = trim((string) $request->post('improvements', '')) ?: null;
$goals = trim((string) $request->post('goals', '')) ?: null;
$reviewerComments = trim((string) $request->post('reviewer_comments', '')) ?: null;
$errors = [];
if ($overallRating < 1 || $overallRating > 5) {
$errors[] = 'التقييم العام يجب أن يكون بين 1 و 5';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/hr/performance/reviews/' . $id . '/edit');
}
$review->update([
'reviewer_id' => $employee ? (int) $employee->id : null,
'scores_json' => is_array($scoresJson) ? json_encode($scoresJson, JSON_UNESCAPED_UNICODE) : null,
'overall_rating' => $overallRating,
'strengths' => $strengths,
'improvements' => $improvements,
'goals' => $goals,
'reviewer_comments' => $reviewerComments,
'review_date' => date('Y-m-d'),
'status' => 'completed',
]);
return $this->redirect('/hr/performance/reviews/' . $id)->withSuccess('تم حفظ تقييم الأداء بنجاح');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrSalaryStructure;
use App\Modules\HR\Models\HrSalaryComponent;
class SalaryStructureController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$structures = $db->select(
"SELECT ss.*, (SELECT COUNT(*) FROM hr_salary_components sc WHERE sc.salary_structure_id = ss.id AND sc.is_archived = 0) as component_count
FROM hr_salary_structures ss
WHERE ss.is_archived = 0
ORDER BY ss.is_default DESC, ss.name_ar ASC"
);
return $this->view('HR.Views.salary_structures.index', [
'structures' => $structures,
]);
}
public function create(Request $request): Response
{
return $this->view('HR.Views.salary_structures.form', [
'structure' => null,
'components' => [],
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/salary-structures/create');
}
$structure = HrSalaryStructure::create($data);
return $this->redirect('/hr/salary-structures/' . $structure->id)->withSuccess('تم إنشاء هيكل الرواتب بنجاح');
}
public function show(Request $request, string $id): Response
{
$structure = HrSalaryStructure::find((int) $id);
if (!$structure) {
return $this->redirect('/hr/salary-structures')->withError('هيكل الرواتب غير موجود');
}
$components = HrSalaryStructure::getComponents((int) $id);
$db = App::getInstance()->db();
$employeeCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_profiles WHERE salary_structure_id = ? AND employment_status = 'active' AND is_archived = 0",
[(int) $id]
);
$earnings = [];
$deductions = [];
foreach ($components as $comp) {
if ($comp['type'] === 'earning') {
$earnings[] = $comp;
} else {
$deductions[] = $comp;
}
}
return $this->view('HR.Views.salary_structures.show', [
'structure' => $structure,
'earnings' => $earnings,
'deductions' => $deductions,
'employeeCount' => (int) ($employeeCount['cnt'] ?? 0),
]);
}
public function edit(Request $request, string $id): Response
{
$structure = HrSalaryStructure::find((int) $id);
if (!$structure) {
return $this->redirect('/hr/salary-structures')->withError('هيكل الرواتب غير موجود');
}
$components = HrSalaryStructure::getComponents((int) $id);
return $this->view('HR.Views.salary_structures.form', [
'structure' => $structure,
'components' => $components,
]);
}
public function update(Request $request, string $id): Response
{
$structure = HrSalaryStructure::find((int) $id);
if (!$structure) {
return $this->redirect('/hr/salary-structures')->withError('هيكل الرواتب غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/salary-structures/' . $id . '/edit');
}
$structure->update($data);
return $this->redirect('/hr/salary-structures/' . $id)->withSuccess('تم تحديث هيكل الرواتب بنجاح');
}
public function updateComponents(Request $request, string $id): Response
{
$structure = HrSalaryStructure::find((int) $id);
if (!$structure) {
return $this->redirect('/hr/salary-structures')->withError('هيكل الرواتب غير موجود');
}
$db = App::getInstance()->db();
$employee = $this->currentEmployee();
$componentsData = $request->post('components', []);
if (!is_array($componentsData)) {
return $this->redirect('/hr/salary-structures/' . $id)->withError('بيانات المكونات غير صالحة');
}
$db->beginTransaction();
try {
// Update existing components
foreach ($componentsData as $compId => $compData) {
if (str_starts_with((string) $compId, 'new_')) {
// New component
$name = trim((string) ($compData['name_ar'] ?? ''));
if ($name === '') {
continue;
}
$db->insert('hr_salary_components', [
'salary_structure_id' => (int) $id,
'name_ar' => $name,
'name_en' => trim((string) ($compData['name_en'] ?? '')) ?: null,
'code' => strtoupper(trim((string) ($compData['code'] ?? ''))),
'type' => trim((string) ($compData['type'] ?? 'earning')),
'calculation_type' => trim((string) ($compData['calculation_type'] ?? 'fixed')),
'default_amount' => trim((string) ($compData['default_amount'] ?? '0.00')),
'percentage_of' => trim((string) ($compData['percentage_of'] ?? '')) ?: null,
'is_taxable' => (int) ($compData['is_taxable'] ?? 0),
'is_insurable' => (int) ($compData['is_insurable'] ?? 0),
'sort_order' => (int) ($compData['sort_order'] ?? 0),
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
} else {
// Existing component
$db->update('hr_salary_components', [
'name_ar' => trim((string) ($compData['name_ar'] ?? '')),
'name_en' => trim((string) ($compData['name_en'] ?? '')) ?: null,
'type' => trim((string) ($compData['type'] ?? 'earning')),
'calculation_type' => trim((string) ($compData['calculation_type'] ?? 'fixed')),
'default_amount' => trim((string) ($compData['default_amount'] ?? '0.00')),
'percentage_of' => trim((string) ($compData['percentage_of'] ?? '')) ?: null,
'is_taxable' => (int) ($compData['is_taxable'] ?? 0),
'is_insurable' => (int) ($compData['is_insurable'] ?? 0),
'sort_order' => (int) ($compData['sort_order'] ?? 0),
'is_active' => (int) ($compData['is_active'] ?? 1),
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ? AND `salary_structure_id` = ?', [(int) $compId, (int) $id]);
}
}
$db->commit();
return $this->redirect('/hr/salary-structures/' . $id)->withSuccess('تم تحديث مكونات الهيكل بنجاح');
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect('/hr/salary-structures/' . $id)->withError('فشل تحديث المكونات: ' . $e->getMessage());
}
}
private function extractData(Request $request): array
{
return [
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'code' => strtoupper(trim((string) $request->post('code', ''))),
'description' => trim((string) $request->post('description', '')) ?: null,
'is_default' => (int) ($request->post('is_default', 0)),
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validateInput(array $data): array
{
$errors = [];
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم هيكل الرواتب بالعربي مطلوب';
}
if ($data['code'] === '') {
$errors[] = 'كود هيكل الرواتب مطلوب';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrTaxRecord;
use App\Modules\HR\Models\HrEmployeeProfile;
class TaxController extends Controller
{
public function index(Request $request): Response
{
$year = (int) $request->get('year', (int) date('Y'));
$db = App::getInstance()->db();
// Monthly summary
$monthlySummary = $db->select(
"SELECT pp.month,
COUNT(tr.id) as employee_count,
SUM(tr.gross_taxable_income) as total_gross_taxable,
SUM(tr.tax_amount) as total_tax
FROM hr_payroll_periods pp
LEFT JOIN hr_tax_records tr ON tr.period_id = pp.id AND tr.is_archived = 0
WHERE pp.year = ? AND pp.is_archived = 0
GROUP BY pp.month
ORDER BY pp.month ASC",
[$year]
);
// YTD totals
$ytdTotals = $db->selectOne(
"SELECT SUM(tr.gross_taxable_income) as ytd_gross_taxable,
SUM(tr.tax_amount) as ytd_tax
FROM hr_tax_records tr
JOIN hr_payroll_periods pp ON pp.id = tr.period_id
WHERE pp.year = ? AND tr.is_archived = 0 AND pp.is_archived = 0",
[$year]
);
return $this->view('HR.Views.tax.index', [
'monthlySummary' => $monthlySummary,
'ytdTotals' => $ytdTotals,
'year' => $year,
]);
}
public function employeeHistory(Request $request, string $employeeId): Response
{
$profile = HrEmployeeProfile::find((int) $employeeId);
if (!$profile) {
return $this->redirect('/hr/tax')->withError('الموظف غير موجود');
}
$year = (int) $request->get('year', (int) date('Y'));
$records = HrTaxRecord::getForEmployee((int) $employeeId, $year);
// YTD for this employee
$ytd = HrTaxRecord::getYtdForEmployee((int) $employeeId, $year);
return $this->view('HR.Views.tax.employee_history', [
'profile' => $profile,
'records' => $records,
'ytd' => $ytd,
'year' => $year,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrWorkSchedule;
class WorkScheduleController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$schedules = $db->select(
"SELECT ws.*,
(SELECT COUNT(*) FROM hr_employee_schedules es WHERE es.schedule_id = ws.id AND es.is_active = 1 AND es.is_archived = 0) as employee_count
FROM hr_work_schedules ws
WHERE ws.is_archived = 0
ORDER BY ws.name_ar ASC"
);
return $this->view('HR.Views.schedules.index', [
'schedules' => $schedules,
]);
}
public function create(Request $request): Response
{
return $this->view('HR.Views.schedules.form', [
'schedule' => null,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/schedules/create');
}
HrWorkSchedule::create($data);
return $this->redirect('/hr/schedules')->withSuccess('تم إنشاء جدول العمل بنجاح');
}
public function edit(Request $request, string $id): Response
{
$schedule = HrWorkSchedule::find((int) $id);
if (!$schedule) {
return $this->redirect('/hr/schedules')->withError('جدول العمل غير موجود');
}
return $this->view('HR.Views.schedules.form', [
'schedule' => $schedule,
]);
}
public function update(Request $request, string $id): Response
{
$schedule = HrWorkSchedule::find((int) $id);
if (!$schedule) {
return $this->redirect('/hr/schedules')->withError('جدول العمل غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validateInput($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/schedules/' . $id . '/edit');
}
$schedule->update($data);
return $this->redirect('/hr/schedules')->withSuccess('تم تحديث جدول العمل بنجاح');
}
private function extractData(Request $request): array
{
// Build days_config JSON from form inputs
$daysOfWeek = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
$daysConfig = [];
foreach ($daysOfWeek as $day) {
$isWorking = (int) ($request->post('day_' . $day . '_working', 0));
$daysConfig[$day] = [
'is_working' => (bool) $isWorking,
'start_time' => $isWorking ? trim((string) $request->post('day_' . $day . '_start', '09:00')) : null,
'end_time' => $isWorking ? trim((string) $request->post('day_' . $day . '_end', '17:00')) : null,
];
}
return [
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'code' => strtoupper(trim((string) $request->post('code', ''))),
'schedule_type' => trim((string) $request->post('schedule_type', 'fixed')),
'working_days_per_week' => (int) $request->post('working_days_per_week', 6),
'hours_per_day' => trim((string) $request->post('hours_per_day', '8.00')),
'hours_per_week' => trim((string) $request->post('hours_per_week', '48.00')),
'ramadan_hours_per_day' => trim((string) $request->post('ramadan_hours_per_day', '')) ?: null,
'is_night_shift' => (int) ($request->post('is_night_shift', 0)),
'days_config' => json_encode($daysConfig, JSON_UNESCAPED_UNICODE),
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validateInput(array $data): array
{
$errors = [];
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم جدول العمل بالعربي مطلوب';
}
if ($data['code'] === '') {
$errors[] = 'كود جدول العمل مطلوب';
}
if ((int) $data['working_days_per_week'] < 1 || (int) $data['working_days_per_week'] > 7) {
$errors[] = 'عدد أيام العمل يجب أن يكون بين 1 و 7';
}
if (bccomp($data['hours_per_day'], '0', 2) <= 0 || bccomp($data['hours_per_day'], '12', 2) > 0) {
$errors[] = 'ساعات العمل اليومية يجب أن تكون بين 0 و 12';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrAttendance extends Model
{
protected static string $table = 'hr_attendance';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'attendance_date', 'schedule_id',
'check_in_time', 'check_out_time', 'check_in_method', 'check_out_method',
'expected_hours', 'actual_hours', 'overtime_hours',
'late_minutes', 'early_leave_minutes',
'status', 'is_ramadan', 'notes',
];
public static function getStatuses(): array
{
return [
'present' => 'حاضر',
'absent' => 'غائب',
'late' => 'متأخر',
'half_day' => 'نصف يوم',
'on_leave' => 'في إجازة',
'holiday' => 'عطلة رسمية',
'rest_day' => 'يوم راحة',
];
}
public static function getCheckMethods(): array
{
return [
'manual' => 'يدوي',
'fingerprint' => 'بصمة',
'card' => 'كارت',
'face' => 'وجه',
];
}
public static function getForEmployeeDate(int $profileId, string $date): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM hr_attendance WHERE employee_profile_id = ? AND attendance_date = ?",
[$profileId, $date]
);
}
public static function getMonthly(int $profileId, int $year, int $month): array
{
$db = App::getInstance()->db();
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = date('Y-m-t', strtotime($startDate));
return $db->select(
"SELECT * FROM hr_attendance
WHERE employee_profile_id = ? AND attendance_date BETWEEN ? AND ?
ORDER BY attendance_date ASC",
[$profileId, $startDate, $endDate]
);
}
public static function getMonthlySummary(int $profileId, int $year, int $month): array
{
$db = App::getInstance()->db();
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = date('Y-m-t', strtotime($startDate));
$row = $db->selectOne(
"SELECT
COUNT(*) as total_days,
SUM(CASE WHEN status = 'present' THEN 1 ELSE 0 END) as present_days,
SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_days,
SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_days,
SUM(CASE WHEN status = 'on_leave' THEN 1 ELSE 0 END) as leave_days,
SUM(CASE WHEN status = 'holiday' THEN 1 ELSE 0 END) as holiday_days,
COALESCE(SUM(actual_hours), 0) as total_actual_hours,
COALESCE(SUM(overtime_hours), 0) as total_overtime_hours,
COALESCE(SUM(late_minutes), 0) as total_late_minutes,
COALESCE(SUM(early_leave_minutes), 0) as total_early_leave_minutes
FROM hr_attendance
WHERE employee_profile_id = ? AND attendance_date BETWEEN ? AND ?",
[$profileId, $startDate, $endDate]
);
return $row ?: [];
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['employee_profile_id'])) {
$where .= ' AND a.employee_profile_id = ?';
$params[] = (int) $filters['employee_profile_id'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND a.attendance_date >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND a.attendance_date <= ?';
$params[] = $filters['date_to'];
}
if (!empty($filters['status'])) {
$where .= ' AND a.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['department_id'])) {
$where .= ' AND p.department_id = ?';
$params[] = (int) $filters['department_id'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_attendance a JOIN hr_employee_profiles p ON p.id = a.employee_profile_id WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT a.*, p.full_name_ar, p.employee_number, d.name_ar as department_name
FROM hr_attendance a
JOIN hr_employee_profiles p ON p.id = a.employee_profile_id
LEFT JOIN hr_departments d ON d.id = p.department_id
WHERE {$where}
ORDER BY a.attendance_date DESC, p.full_name_ar ASC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrContract extends Model
{
protected static string $table = 'hr_contracts';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'contract_number', 'contract_type',
'start_date', 'end_date', 'probation_months',
'working_hours_per_day', 'working_days_per_week',
'notice_period_months', 'basic_salary', 'total_salary',
'salary_structure_id', 'previous_contract_id',
'signed_copy_document_id', 'status', 'notes',
];
public static function getContractTypes(): array
{
return [
'definite' => 'محدد المدة',
'indefinite' => 'غير محدد المدة',
];
}
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'pending_approval' => 'في انتظار الاعتماد',
'active' => 'ساري',
'renewed' => 'تم التجديد',
'expired' => 'منتهي',
'terminated' => 'تم إنهاؤه',
];
}
public static function getActiveForEmployee(int $profileId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM hr_contracts WHERE employee_profile_id = ? AND status = 'active' AND is_archived = 0 ORDER BY start_date DESC LIMIT 1",
[$profileId]
);
}
public static function getExpiringWithinDays(int $days): array
{
$db = App::getInstance()->db();
$futureDate = date('Y-m-d', strtotime("+{$days} days"));
return $db->select(
"SELECT c.*, p.full_name_ar, p.employee_number
FROM hr_contracts c
JOIN hr_employee_profiles p ON p.id = c.employee_profile_id
WHERE c.status = 'active'
AND c.contract_type = 'definite'
AND c.end_date IS NOT NULL
AND c.end_date <= ?
AND c.end_date >= CURDATE()
AND c.is_archived = 0
ORDER BY c.end_date ASC",
[$futureDate]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'c.is_archived = 0';
$params = [];
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where .= ' AND (p.full_name_ar LIKE ? OR c.contract_number LIKE ?)';
$params[] = $search;
$params[] = $search;
}
if (!empty($filters['status'])) {
$where .= ' AND c.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['contract_type'])) {
$where .= ' AND c.contract_type = ?';
$params[] = $filters['contract_type'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_contracts c JOIN hr_employee_profiles p ON p.id = c.employee_profile_id WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT c.*, p.full_name_ar, p.employee_number
FROM hr_contracts c
JOIN hr_employee_profiles p ON p.id = c.employee_profile_id
WHERE {$where}
ORDER BY c.start_date DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrDepartment extends Model
{
protected static string $table = 'hr_departments';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code', 'name_ar', 'name_en', 'parent_id', 'manager_employee_id',
'branch_id', 'description', 'is_active',
];
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('name_ar', 'ASC')
->get();
}
public static function getTree(): array
{
$all = static::allActive();
return self::buildTree($all, null);
}
private static function buildTree(array $items, ?int $parentId): array
{
$tree = [];
foreach ($items as $item) {
$pid = $item['parent_id'] ? (int) $item['parent_id'] : null;
if ($pid === $parentId) {
$children = self::buildTree($items, (int) $item['id']);
$item['children'] = $children;
$tree[] = $item;
}
}
return $tree;
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'd.is_archived = 0';
$params = [];
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where .= ' AND (d.name_ar LIKE ? OR d.name_en LIKE ? OR d.code LIKE ?)';
$params[] = $search;
$params[] = $search;
$params[] = $search;
}
if (!empty($filters['branch_id'])) {
$where .= ' AND d.branch_id = ?';
$params[] = (int) $filters['branch_id'];
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$where .= ' AND d.is_active = ?';
$params[] = (int) $filters['is_active'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM hr_departments d WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT d.*, p.name_ar as parent_name, b.name_ar as branch_name
FROM hr_departments d
LEFT JOIN hr_departments p ON p.id = d.parent_id
LEFT JOIN branches b ON b.id = d.branch_id
WHERE {$where}
ORDER BY d.name_ar ASC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrDisciplinaryAction extends Model
{
protected static string $table = 'hr_disciplinary_actions';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'incident_date', 'reported_date', 'incident_description',
'investigation_notes', 'investigated_by', 'investigation_date',
'penalty_type', 'penalty_details_json',
'wage_deduction_days', 'suspension_days',
'decision_date', 'decided_by', 'decision_notes',
'appeal_date', 'appeal_reason', 'appeal_decision', 'appeal_decided_by', 'appeal_decision_date',
'enforcement_date', 'status', 'workflow_instance_id', 'notes',
];
public static function getPenaltyTypes(): array
{
return [
'warning' => 'إنذار',
'deduction' => 'خصم من الراتب',
'suspension' => 'إيقاف عن العمل',
'demotion' => 'تخفيض الدرجة',
'termination' => 'فصل من العمل',
];
}
public static function getStatuses(): array
{
return [
'reported' => 'تم الإبلاغ',
'under_investigation' => 'تحت التحقيق',
'investigation_done' => 'انتهى التحقيق',
'penalty_decided' => 'تم تحديد العقوبة',
'enforced' => 'تم التنفيذ',
'appealed' => 'تم التظلم',
'appeal_reviewed' => 'تم مراجعة التظلم',
'upheld' => 'تم تأييد العقوبة',
'modified' => 'تم تعديل العقوبة',
'overturned' => 'تم إلغاء العقوبة',
'dismissed' => 'تم الحفظ',
'closed' => 'مغلق',
];
}
public static function getMonthlyDeductionDays(int $profileId, int $year, int $month): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(wage_deduction_days), 0) as total
FROM hr_disciplinary_actions
WHERE employee_profile_id = ?
AND YEAR(enforcement_date) = ? AND MONTH(enforcement_date) = ?
AND penalty_type = 'deduction'
AND status IN ('enforced', 'upheld', 'closed')
AND is_archived = 0",
[$profileId, $year, $month]
);
return (int) ($row['total'] ?? 0);
}
public static function getMonthlySuspensionDays(int $profileId, int $year, int $month): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(suspension_days), 0) as total
FROM hr_disciplinary_actions
WHERE employee_profile_id = ?
AND YEAR(enforcement_date) = ? AND MONTH(enforcement_date) = ?
AND penalty_type = 'suspension'
AND status IN ('enforced', 'upheld', 'closed')
AND is_archived = 0",
[$profileId, $year, $month]
);
return (int) ($row['total'] ?? 0);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'da.is_archived = 0';
$params = [];
if (!empty($filters['employee_profile_id'])) {
$where .= ' AND da.employee_profile_id = ?';
$params[] = (int) $filters['employee_profile_id'];
}
if (!empty($filters['status'])) {
$where .= ' AND da.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['penalty_type'])) {
$where .= ' AND da.penalty_type = ?';
$params[] = $filters['penalty_type'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_disciplinary_actions da
JOIN hr_employee_profiles p ON p.id = da.employee_profile_id WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT da.*, p.full_name_ar, p.employee_number
FROM hr_disciplinary_actions da
JOIN hr_employee_profiles p ON p.id = da.employee_profile_id
WHERE {$where}
ORDER BY da.reported_date DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrEmployeeDocument extends Model
{
protected static string $table = 'hr_employee_documents';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'document_type', 'document_number',
'file_path', 'file_name', 'issue_date', 'expiry_date',
'issuing_authority', 'is_verified', 'verified_by', 'verified_at',
'notes',
];
public static function getDocumentTypes(): array
{
return [
'national_id' => 'بطاقة الرقم القومي',
'passport' => 'جواز سفر',
'birth_certificate'=> 'شهادة ميلاد',
'degree' => 'شهادة علمية',
'military_cert' => 'شهادة تأدية/إعفاء من الخدمة العسكرية',
'insurance_form1' => 'استمارة تأمينات 1',
'insurance_form6' => 'استمارة تأمينات 6',
'medical_report' => 'تقرير طبي',
'criminal_record' => 'صحيفة حالة جنائية',
'employment_cert' => 'شهادة خبرة',
'contract_copy' => 'نسخة عقد',
'photo' => 'صورة شخصية',
'other' => 'أخرى',
];
}
public static function getForEmployee(int $profileId): array
{
return static::query()
->where('employee_profile_id', '=', $profileId)
->orderBy('created_at', 'DESC')
->get();
}
public static function getExpiringWithinDays(int $days): array
{
$db = App::getInstance()->db();
$futureDate = date('Y-m-d', strtotime("+{$days} days"));
return $db->select(
"SELECT d.*, p.full_name_ar, p.employee_number
FROM hr_employee_documents d
JOIN hr_employee_profiles p ON p.id = d.employee_profile_id
WHERE d.expiry_date IS NOT NULL
AND d.expiry_date <= ?
AND d.expiry_date >= CURDATE()
AND d.is_archived = 0
ORDER BY d.expiry_date ASC",
[$futureDate]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'd.is_archived = 0';
$params = [];
if (!empty($filters['employee_profile_id'])) {
$where .= ' AND d.employee_profile_id = ?';
$params[] = (int) $filters['employee_profile_id'];
}
if (!empty($filters['document_type'])) {
$where .= ' AND d.document_type = ?';
$params[] = $filters['document_type'];
}
if (isset($filters['is_verified']) && $filters['is_verified'] !== '') {
$where .= ' AND d.is_verified = ?';
$params[] = (int) $filters['is_verified'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_documents d WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT d.*, p.full_name_ar, p.employee_number
FROM hr_employee_documents d
JOIN hr_employee_profiles p ON p.id = d.employee_profile_id
WHERE {$where}
ORDER BY d.created_at DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrEmployeeLoan extends Model
{
protected static string $table = 'hr_employee_loans';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'loan_type', 'loan_amount', 'installment_amount',
'number_of_installments', 'remaining_amount', 'paid_installments',
'request_date', 'start_deduction_date', 'reason',
'status', 'workflow_instance_id',
'approved_by', 'approved_at', 'disbursed_date', 'notes',
];
public static function getLoanTypes(): array
{
return [
'salary_advance' => 'سلفة راتب',
'personal_loan' => 'قرض شخصي',
'emergency_loan' => 'سلفة طوارئ',
];
}
public static function getStatuses(): array
{
return [
'pending' => 'في الانتظار',
'approved' => 'معتمد',
'disbursed' => 'تم الصرف',
'repaying' => 'جاري السداد',
'settled' => 'تم السداد',
'rejected' => 'مرفوض',
'cancelled' => 'ملغى',
];
}
public static function getActiveForEmployee(int $profileId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM hr_employee_loans
WHERE employee_profile_id = ?
AND status IN ('disbursed', 'repaying')
AND is_archived = 0
ORDER BY start_deduction_date ASC",
[$profileId]
);
}
public static function getDueInstallments(int $profileId, int $year, int $month): array
{
$db = App::getInstance()->db();
$periodDate = sprintf('%04d-%02d-01', $year, $month);
return $db->select(
"SELECT li.*, el.loan_type, el.loan_amount
FROM hr_loan_installments li
JOIN hr_employee_loans el ON el.id = li.loan_id
WHERE el.employee_profile_id = ?
AND li.due_date <= LAST_DAY(?)
AND li.due_date >= ?
AND li.status = 'pending'
ORDER BY li.due_date ASC",
[$profileId, $periodDate, $periodDate]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'el.is_archived = 0';
$params = [];
if (!empty($filters['employee_profile_id'])) {
$where .= ' AND el.employee_profile_id = ?';
$params[] = (int) $filters['employee_profile_id'];
}
if (!empty($filters['status'])) {
$where .= ' AND el.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['loan_type'])) {
$where .= ' AND el.loan_type = ?';
$params[] = $filters['loan_type'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_loans el
JOIN hr_employee_profiles p ON p.id = el.employee_profile_id WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT el.*, p.full_name_ar, p.employee_number
FROM hr_employee_loans el
JOIN hr_employee_profiles p ON p.id = el.employee_profile_id
WHERE {$where}
ORDER BY el.request_date DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrEmployeeProfile extends Model
{
protected static string $table = 'hr_employee_profiles';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_id', 'employee_number', 'national_id', 'full_name_ar', 'full_name_en',
'date_of_birth', 'gender', 'marital_status', 'religion', 'nationality',
'phone', 'email', 'address',
'department_id', 'job_title_id', 'salary_structure_id',
'hire_date', 'probation_end_date', 'employment_type', 'employment_status',
'basic_salary', 'insurable_salary',
'bank_name', 'bank_account_number', 'bank_iban',
'insurance_number', 'insurance_start_date',
'military_status', 'has_disability', 'disability_description',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'notes',
];
public static function getEmploymentStatuses(): array
{
return [
'active' => 'نشط',
'on_probation' => 'تحت الاختبار',
'suspended' => 'موقوف',
'on_leave' => 'في إجازة',
'terminated' => 'منتهي الخدمة',
'resigned' => 'مستقيل',
'retired' => 'متقاعد',
];
}
public static function getEmploymentTypes(): array
{
return [
'full_time' => 'دوام كامل',
'part_time' => 'دوام جزئي',
'contract' => 'عقد مؤقت',
'seasonal' => 'موسمي',
];
}
public static function getGenders(): array
{
return ['male' => 'ذكر', 'female' => 'أنثى'];
}
public static function getMaritalStatuses(): array
{
return [
'single' => 'أعزب',
'married' => 'متزوج',
'divorced' => 'مطلق',
'widowed' => 'أرمل',
];
}
public static function getReligions(): array
{
return ['muslim' => 'مسلم', 'christian' => 'مسيحي', 'other' => 'أخرى'];
}
public static function getMilitaryStatuses(): array
{
return [
'completed' => 'أدى الخدمة',
'exempted' => 'معفى',
'postponed' => 'مؤجل',
'not_applicable' => 'لا ينطبق',
];
}
public static function findByEmployeeId(int $employeeId): ?static
{
$row = static::query()->where('employee_id', '=', $employeeId)->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function allActive(): array
{
return static::query()
->where('employment_status', '=', 'active')
->orderBy('full_name_ar', 'ASC')
->get();
}
public static function getActiveIds(): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT id, employee_id FROM hr_employee_profiles WHERE employment_status IN ('active','on_probation') AND is_archived = 0"
);
return $rows;
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'p.is_archived = 0';
$params = [];
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where .= ' AND (p.full_name_ar LIKE ? OR p.full_name_en LIKE ? OR p.employee_number LIKE ? OR p.national_id LIKE ?)';
$params[] = $search;
$params[] = $search;
$params[] = $search;
$params[] = $search;
}
if (!empty($filters['department_id'])) {
$where .= ' AND p.department_id = ?';
$params[] = (int) $filters['department_id'];
}
if (!empty($filters['employment_status'])) {
$where .= ' AND p.employment_status = ?';
$params[] = $filters['employment_status'];
}
if (!empty($filters['employment_type'])) {
$where .= ' AND p.employment_type = ?';
$params[] = $filters['employment_type'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM hr_employee_profiles p WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT p.*, d.name_ar as department_name, j.name_ar as job_title_name
FROM hr_employee_profiles p
LEFT JOIN hr_departments d ON d.id = p.department_id
LEFT JOIN hr_job_titles j ON j.id = p.job_title_id
WHERE {$where}
ORDER BY p.full_name_ar ASC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getYearsOfService(string $hireDate): string
{
$hire = new \DateTime($hireDate);
$now = new \DateTime();
$diff = $hire->diff($now);
$years = $diff->y + ($diff->m / 12) + ($diff->d / 365);
return number_format($years, 2, '.', '');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
class HrEmployeeSalaryDetail extends Model
{
protected static string $table = 'hr_employee_salary_details';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'component_id', 'amount', 'effective_from',
'effective_to', 'notes',
];
public static function getForEmployee(int $profileId, ?string $asOfDate = null): array
{
$db = App::getInstance()->db();
$date = $asOfDate ?? date('Y-m-d');
return $db->select(
"SELECT esd.*, sc.component_code, sc.name_ar, sc.name_en, sc.type,
sc.calculation_type, sc.percentage_of, sc.percentage_value,
sc.is_taxable, sc.is_insurable, sc.is_basic, sc.is_variable,
sc.is_system_calculated, sc.affects_overtime
FROM hr_employee_salary_details esd
JOIN hr_salary_components sc ON sc.id = esd.component_id
WHERE esd.employee_profile_id = ?
AND esd.effective_from <= ?
AND (esd.effective_to IS NULL OR esd.effective_to >= ?)
ORDER BY sc.sort_order ASC",
[$profileId, $date, $date]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class HrEndOfService extends Model
{
protected static string $table = 'hr_end_of_service';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_id', 'termination_type', 'request_date', 'effective_date',
'last_working_day', 'notice_period_start', 'notice_period_end',
'notice_period_served', 'notice_period_compensation',
'years_of_service', 'last_basic_salary', 'last_total_salary',
'severance_first_5y', 'severance_after_5y', 'total_severance',
'remaining_leave_days', 'leave_compensation', 'total_entitlement',
'deductions_json', 'total_deductions', 'net_settlement',
'status', 'workflow_instance_id',
'approved_by', 'approved_at', 'paid_date',
'calculation_json', 'notes',
];
public static function getTerminationTypes(): array
{
return [
'resignation' => 'استقالة',
'termination' => 'فصل',
'end_of_contract' => 'انتهاء عقد',
'retirement' => 'تقاعد',
'death' => 'وفاة',
'disability' => 'عجز',
'mutual_agreement' => 'اتفاق مشترك',
];
}
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'calculated' => 'تم الحساب',
'pending_approval' => 'في انتظار الاعتماد',
'approved' => 'معتمد',
'paid' => 'تم الصرف',
'cancelled' => 'ملغى',
];
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'eos.is_archived = 0';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND eos.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['termination_type'])) {
$where .= ' AND eos.termination_type = ?';
$params[] = $filters['termination_type'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_end_of_service eos
JOIN hr_employee_profiles p ON p.employee_id = eos.employee_id WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT eos.*, p.full_name_ar, p.employee_number
FROM hr_end_of_service eos
JOIN hr_employee_profiles p ON p.employee_id = eos.employee_id
WHERE {$where}
ORDER BY eos.request_date DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
class HrHoliday extends Model
{
protected static string $table = 'hr_holidays';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'name_ar', 'name_en', 'holiday_type', 'date', 'duration_days',
'is_recurring', 'recurring_month', 'recurring_day', 'religion', 'is_active',
];
public static function getHolidayTypes(): array
{
return [
'national' => 'عطلة رسمية',
'religious' => 'عطلة دينية',
'company' => 'عطلة خاصة بالنادي',
];
}
public static function getForYear(int $year): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM hr_holidays
WHERE is_active = 1 AND is_archived = 0
AND (YEAR(`date`) = ? OR is_recurring = 1)
ORDER BY `date` ASC",
[$year]
);
}
public static function isHoliday(string $date, ?string $religion = null): bool
{
$db = App::getInstance()->db();
$month = (int) date('m', strtotime($date));
$day = (int) date('d', strtotime($date));
$where = "is_active = 1 AND is_archived = 0 AND ((`date` = ?) OR (is_recurring = 1 AND recurring_month = ? AND recurring_day = ?))";
$params = [$date, $month, $day];
if ($religion !== null) {
$where .= " AND (religion IS NULL OR religion = ?)";
$params[] = $religion;
} else {
$where .= " AND religion IS NULL";
}
$row = $db->selectOne("SELECT id FROM hr_holidays WHERE {$where} LIMIT 1", $params);
return $row !== null;
}
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('date', 'ASC')
->get();
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
class HrInsuranceRecord extends Model
{
protected static string $table = 'hr_insurance_records';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_profile_id', 'period_id', 'payroll_run_id',
'basic_insurable_salary', 'variable_insurable_salary',
'employee_basic_share', 'employee_variable_share', 'total_employee_share',
'employer_basic_share', 'employer_variable_share', 'total_employer_share',
'total_contribution', 'notes',
];
public static function getForPeriod(int $periodId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ir.*, p.full_name_ar, p.employee_number, p.insurance_number
FROM hr_insurance_records ir
JOIN hr_employee_profiles p ON p.id = ir.employee_profile_id
WHERE ir.period_id = ?
ORDER BY p.full_name_ar ASC",
[$periodId]
);
}
public static function getForEmployee(int $profileId, ?int $year = null): array
{
$db = App::getInstance()->db();
$where = 'ir.employee_profile_id = ?';
$params = [$profileId];
if ($year) {
$where .= ' AND pp.year = ?';
$params[] = $year;
}
return $db->select(
"SELECT ir.*, pp.year, pp.month, pp.period_code
FROM hr_insurance_records ir
JOIN hr_payroll_periods pp ON pp.id = ir.period_id
WHERE {$where}
ORDER BY pp.year DESC, pp.month DESC",
$params
);
}
public static function getPeriodTotals(int $periodId): array
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT
COUNT(*) as employee_count,
COALESCE(SUM(total_employee_share), 0) as total_employee,
COALESCE(SUM(total_employer_share), 0) as total_employer,
COALESCE(SUM(total_contribution), 0) as grand_total
FROM hr_insurance_records WHERE period_id = ?",
[$periodId]
);
return $row ?: [];
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment