Commit 39b96a83 authored by Fares's avatar Fares

feat(hr): comprehensive 30-point HR module overhaul

- Employee profile: workforce type, fingerprint code, education fields,
  expanded military status (6 values), comprehensive salary display
- Department staffing: authorized positions per job title, capacity tracking
- Payroll: martyrs fund, stamp duties, external loan deductions (configurable)
- Leave: configurable annual days from system_config, unlimited unpaid,
  breastfeeding hour tolerance integrated into attendance tardiness
- Biometric: device management, punch reception API, attendance integration
- Insurance Form S2 (wage adjustment notification), contract work receipt
- Overtime fix: employee_id → employee_profile_id throughout
- Disciplinary: investigation_result field
- Loans: financing_company/bank_loan types with external entity fields
- Documents: validity_status tracking, new types (work_stub, insurance_cert)
- Label changes: الأجر التأميني, الرقم التأميني, الراتب الشامل
- All HR permissions seeded to super_admin role
- Architecture Map and Dependency Graph updated
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 016b6717
This diff is collapsed.
......@@ -197,6 +197,42 @@ class ContractController extends Controller
return $this->redirect('/hr/contracts/' . $result['contract_id'])->withSuccess('تم تجديد العقد بنجاح');
}
public function workReceipt(Request $request, int $id): Response
{
$this->authorize('hr.contract.view');
$contract = HrContract::find($id);
if (!$contract) {
return $this->redirect('/hr/contracts')->withError('العقد غير موجود');
}
$employee = HrEmployeeProfile::find((int) $contract->employee_profile_id);
if (!$employee) {
return $this->redirect('/hr/contracts/' . $id)->withError('الموظف غير موجود');
}
$db = App::getInstance()->db();
$department = null;
if ($employee->department_id) {
$dept = $db->selectOne("SELECT name_ar FROM hr_departments WHERE id = ?", [(int) $employee->department_id]);
$department = $dept['name_ar'] ?? null;
}
$jobTitle = null;
if ($employee->job_title_id) {
$jt = $db->selectOne("SELECT name_ar FROM hr_job_titles WHERE id = ?", [(int) $employee->job_title_id]);
$jobTitle = $jt['name_ar'] ?? null;
}
return $this->view('HR.Views.contracts.work_receipt', [
'contract' => $contract,
'employee' => $employee,
'department' => $department,
'jobTitle' => $jobTitle,
]);
}
public function terminate(Request $request, string $id): Response
{
$contract = HrContract::find((int) $id);
......
......@@ -8,7 +8,9 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Models\HrDepartmentPosition;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Models\HrJobTitle;
class DepartmentController extends Controller
{
......@@ -91,6 +93,8 @@ class DepartmentController extends Controller
[(int) $id]
);
$positions = HrDepartmentPosition::getStaffingReport((int) $id);
return $this->view('HR.Views.departments.show', [
'department' => $department,
'parent' => $parent,
......@@ -98,6 +102,7 @@ class DepartmentController extends Controller
'branch' => $branch,
'children' => $children,
'employeeCount' => (int) ($employeeCount['cnt'] ?? 0),
'positions' => $positions,
]);
}
......@@ -176,6 +181,90 @@ class DepartmentController extends Controller
return $this->redirect('/hr/departments')->withSuccess('تم حذف القسم بنجاح');
}
public function positions(Request $request, string $id): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$positions = HrDepartmentPosition::getStaffingReport((int) $id);
$jobTitles = HrJobTitle::allActive();
return $this->view('HR.Views.departments.positions', [
'department' => $department,
'positions' => $positions,
'jobTitles' => $jobTitles,
]);
}
public function storePosition(Request $request, string $id): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$jobTitleId = (int) $request->post('job_title_id', 0);
$authorizedCount = (int) $request->post('authorized_count', 0);
$errors = [];
if ($jobTitleId <= 0) {
$errors[] = 'يجب اختيار مسمى وظيفي';
}
if ($authorizedCount <= 0) {
$errors[] = 'العدد المعتمد يجب أن يكون أكبر من صفر';
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/departments/' . $id . '/positions');
}
// Check if position already exists for this department + job title
$db = App::getInstance()->db();
$existing = $db->selectOne(
"SELECT id FROM hr_department_positions WHERE department_id = ? AND job_title_id = ?",
[(int) $id, $jobTitleId]
);
if ($existing) {
$db->update('hr_department_positions', [
'authorized_count' => $authorizedCount,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $existing['id']]);
} else {
HrDepartmentPosition::create([
'department_id' => (int) $id,
'job_title_id' => $jobTitleId,
'authorized_count' => $authorizedCount,
]);
}
return $this->redirect('/hr/departments/' . $id . '/positions')->withSuccess('تم حفظ الوظيفة المعتمدة بنجاح');
}
public function deletePosition(Request $request, string $id, string $posId): Response
{
$department = HrDepartment::find((int) $id);
if (!$department) {
return $this->redirect('/hr/departments')->withError('القسم غير موجود');
}
$db = App::getInstance()->db();
$position = $db->selectOne(
"SELECT id FROM hr_department_positions WHERE id = ? AND department_id = ?",
[(int) $posId, (int) $id]
);
if (!$position) {
return $this->redirect('/hr/departments/' . $id . '/positions')->withError('الوظيفة غير موجودة');
}
$db->delete('hr_department_positions', '`id` = ?', [(int) $posId]);
return $this->redirect('/hr/departments/' . $id . '/positions')->withSuccess('تم حذف الوظيفة المعتمدة بنجاح');
}
private function extractData(Request $request): array
{
return [
......@@ -185,6 +274,7 @@ class DepartmentController extends Controller
'parent_id' => ((int) $request->post('parent_id', 0)) ?: null,
'manager_employee_id' => ((int) $request->post('manager_employee_id', 0)) ?: null,
'branch_id' => ((int) $request->post('branch_id', 0)) ?: null,
'staffing_capacity' => ((int) $request->post('staffing_capacity', 0)) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
];
}
......
......@@ -260,6 +260,7 @@ class DisciplinaryController extends Controller
'incident_date' => trim((string) $request->post('incident_date', '')),
'incident_description' => trim((string) $request->post('incident_description', '')),
'investigation_notes' => trim((string) $request->post('investigation_notes', '')) ?: null,
'investigation_result' => trim((string) $request->post('investigation_result', '')) ?: null,
];
}
......
......@@ -19,8 +19,12 @@ class EmployeeDocumentController extends Controller
'q' => trim((string) $request->get('q', '')),
'document_type' => trim((string) $request->get('document_type', '')),
'expiring_soon' => trim((string) $request->get('expiring_soon', '')),
'validity_status' => trim((string) $request->get('validity_status', '')),
];
// Auto-update validity statuses before displaying
$this->updateValidityStatuses();
$page = max(1, (int) $request->get('page', 1));
$result = HrEmployeeDocument::search($filters, 25, $page);
......@@ -32,6 +36,34 @@ class EmployeeDocumentController extends Controller
]);
}
private function updateValidityStatuses(): void
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$soonDate = date('Y-m-d', strtotime('+30 days'));
// Mark expired documents
$db->query(
"UPDATE hr_employee_documents SET validity_status = 'expired'
WHERE expiry_date IS NOT NULL AND expiry_date < ? AND is_archived = 0",
[$today]
);
// Mark expiring soon (within 30 days)
$db->query(
"UPDATE hr_employee_documents SET validity_status = 'expiring_soon'
WHERE expiry_date IS NOT NULL AND expiry_date >= ? AND expiry_date <= ? AND is_archived = 0",
[$today, $soonDate]
);
// Mark valid (expiry date more than 30 days away)
$db->query(
"UPDATE hr_employee_documents SET validity_status = 'valid'
WHERE expiry_date IS NOT NULL AND expiry_date > ? AND is_archived = 0",
[$soonDate]
);
}
public function byEmployee(Request $request, string $employeeId): Response
{
$profile = HrEmployeeProfile::find((int) $employeeId);
......
......@@ -55,6 +55,8 @@ class EmployeeProfileController extends Controller
ORDER BY e.full_name_ar ASC"
);
$activeProfiles = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.employees.form', [
'profile' => null,
'unlinked' => $unlinked,
......@@ -63,10 +65,12 @@ class EmployeeProfileController extends Controller
'structures' => HrSalaryStructure::allActive(),
'statuses' => HrEmployeeProfile::getStatuses(),
'types' => HrEmployeeProfile::getEmploymentTypes(),
'workforceTypes' => HrEmployeeProfile::getWorkforceTypes(),
'genders' => HrEmployeeProfile::getGenders(),
'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(),
'religions' => HrEmployeeProfile::getReligions(),
'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(),
'managers' => $activeProfiles,
]);
}
......@@ -145,6 +149,7 @@ class EmployeeProfileController extends Controller
);
$yearsOfService = HrEmployeeProfile::getYearsOfService($profile->hire_date ?? date('Y-m-d'));
$comprehensiveSalary = HrEmployeeProfile::getComprehensiveSalary((int) $id);
return $this->view('HR.Views.employees.show', [
'profile' => $profile,
......@@ -155,10 +160,13 @@ class EmployeeProfileController extends Controller
'activeLoans' => $activeLoans,
'recentLeaves' => $recentLeaves,
'yearsOfService' => $yearsOfService,
'comprehensiveSalary' => $comprehensiveSalary,
'statuses' => HrEmployeeProfile::getStatuses(),
'types' => HrEmployeeProfile::getEmploymentTypes(),
'workforceTypes' => HrEmployeeProfile::getWorkforceTypes(),
'genders' => HrEmployeeProfile::getGenders(),
'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(),
'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(),
]);
}
......@@ -169,6 +177,8 @@ class EmployeeProfileController extends Controller
return $this->redirect('/hr/employees')->withError('ملف الموظف غير موجود');
}
$activeProfiles = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.employees.form', [
'profile' => $profile,
'unlinked' => [],
......@@ -177,10 +187,12 @@ class EmployeeProfileController extends Controller
'structures' => HrSalaryStructure::allActive(),
'statuses' => HrEmployeeProfile::getStatuses(),
'types' => HrEmployeeProfile::getEmploymentTypes(),
'workforceTypes' => HrEmployeeProfile::getWorkforceTypes(),
'genders' => HrEmployeeProfile::getGenders(),
'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(),
'religions' => HrEmployeeProfile::getReligions(),
'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(),
'managers' => $activeProfiles,
]);
}
......@@ -363,6 +375,7 @@ class EmployeeProfileController extends Controller
return [
'employee_id' => ((int) $request->post('employee_id', 0)) ?: null,
'fingerprint_code' => trim((string) $request->post('fingerprint_code', '')) ?: null,
'first_name_ar' => trim((string) $request->post('first_name_ar', '')),
'last_name_ar' => trim((string) $request->post('last_name_ar', '')),
'first_name_en' => trim((string) $request->post('first_name_en', '')) ?: null,
......@@ -372,19 +385,29 @@ class EmployeeProfileController extends Controller
'gender' => $gender,
'marital_status' => trim((string) $request->post('marital_status', 'single')),
'religion' => trim((string) $request->post('religion', 'muslim')),
'nationality' => trim((string) $request->post('nationality', 'egyptian')) ?: 'egyptian',
'phone' => trim((string) $request->post('phone', '')) ?: null,
'email' => trim((string) $request->post('email', '')) ?: null,
'address' => trim((string) $request->post('address', '')) ?: null,
'education_qualification' => trim((string) $request->post('education_qualification', '')) ?: null,
'education_specialization' => trim((string) $request->post('education_specialization', '')) ?: null,
'education_graduation_year'=> trim((string) $request->post('education_graduation_year', '')) ?: null,
'education_grade' => trim((string) $request->post('education_grade', '')) ?: null,
'education_university' => trim((string) $request->post('education_university', '')) ?: null,
'department_id' => ((int) $request->post('department_id', 0)) ?: null,
'job_title_id' => ((int) $request->post('job_title_id', 0)) ?: null,
'direct_manager_id' => ((int) $request->post('direct_manager_id', 0)) ?: null,
'salary_structure_id' => ((int) $request->post('salary_structure_id', 0)) ?: null,
'hire_date' => trim((string) $request->post('hire_date', '')),
'employment_type' => trim((string) $request->post('employment_type', 'full_time')),
'workforce_type' => trim((string) $request->post('workforce_type', 'insured')),
'employment_status' => trim((string) $request->post('employment_status', 'active')),
'probation_end_date' => trim((string) $request->post('probation_end_date', '')) ?: null,
'basic_salary' => trim((string) $request->post('basic_salary', '0.00')),
'insurable_salary' => trim((string) $request->post('insurable_salary', '0.00')) ?: '0.00',
'insurance_number' => trim((string) $request->post('insurance_number', '')) ?: null,
'insurance_start_date' => trim((string) $request->post('insurance_start_date', '')) ?: null,
'tax_card_number' => trim((string) $request->post('tax_card_number', '')) ?: null,
'bank_name' => trim((string) $request->post('bank_name', '')) ?: null,
'bank_account_number' => trim((string) $request->post('bank_account_number', '')) ?: null,
'bank_iban' => trim((string) $request->post('bank_iban', '')) ?: null,
......
......@@ -97,6 +97,52 @@ class InsuranceController extends Controller
]);
}
public function form2(Request $request): Response
{
$this->authorize('hr.insurance.view');
$year = (int) $request->get('year', (int) date('Y'));
$month = (int) $request->get('month', (int) date('m'));
$db = App::getInstance()->db();
// Form 2: Wage adjustment notification
// Get salary adjustments (applied/approved) in the specified month from hr_salary_adjustments
$adjustments = $db->select(
"SELECT sa.previous_basic_salary, sa.new_basic_salary, sa.effective_date, sa.reason,
sa.adjustment_type,
CONCAT(hp.first_name_ar, ' ', hp.last_name_ar) as full_name,
hp.insurance_number, hp.national_id
FROM hr_salary_adjustments sa
JOIN employees e ON e.id = sa.employee_id
JOIN hr_employee_profiles hp ON hp.employee_id = e.id
WHERE YEAR(sa.effective_date) = ? AND MONTH(sa.effective_date) = ?
AND sa.status IN ('approved', 'applied')
AND hp.insurance_number IS NOT NULL
AND hp.is_archived = 0
ORDER BY sa.effective_date ASC",
[$year, $month]
);
// Get company info from system_config
$companyName = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'company.name_ar'",
[]
);
$insuranceNo = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'company.insurance_number'",
[]
);
return $this->view('HR.Views.insurance.form2', [
'adjustments' => $adjustments,
'year' => $year,
'month' => $month,
'companyName' => $companyName['config_value'] ?? null,
'insuranceNumber' => $insuranceNo['config_value'] ?? null,
]);
}
public function form6(Request $request): Response
{
$year = (int) $request->get('year', (int) date('Y'));
......
......@@ -111,7 +111,9 @@ class JobTitleController extends Controller
'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_ar'=> trim((string) $request->post('description_ar', '')) ?: null,
'description_ar' => trim((string) $request->post('description_ar', '')) ?: null,
'job_description_ar' => trim((string) $request->post('job_description_ar', '')) ?: null,
'job_description_en' => trim((string) $request->post('job_description_en', '')) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
];
}
......
......@@ -53,6 +53,9 @@ class LoanController extends Controller
'start_deduction_date' => trim((string) $request->post('start_deduction_date', '')),
'reason' => trim((string) $request->post('reason', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
'external_entity_name' => trim((string) $request->post('external_entity_name', '')) ?: null,
'external_reference' => trim((string) $request->post('external_reference', '')) ?: null,
'monthly_salary_impact' => trim((string) $request->post('monthly_salary_impact', '')) ?: null,
];
$result = LoanService::create($data);
......
......@@ -27,10 +27,11 @@ class OvertimeController extends Controller
}
$requests = $db->select(
"SELECT r.*, e.full_name_ar as employee_name, t.name_ar as type_name, t.rate_multiplier,
"SELECT r.*, CONCAT(p.first_name_ar, ' ', p.last_name_ar) as employee_name,
t.name_ar as type_name, t.rate_multiplier,
ap.full_name_ar as approved_by_name
FROM hr_overtime_requests r
JOIN employees e ON e.id = r.employee_id
JOIN hr_employee_profiles p ON p.id = r.employee_profile_id
JOIN hr_overtime_types t ON t.id = r.overtime_type_id
LEFT JOIN employees ap ON ap.id = r.approved_by
WHERE {$where}
......@@ -53,7 +54,7 @@ class OvertimeController extends Controller
$this->authorize('hr.overtime.create');
$db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar, employee_number FROM employees WHERE is_archived = 0 AND employment_status = 'active' ORDER BY full_name_ar");
$employees = $db->select("SELECT id, CONCAT(first_name_ar, ' ', last_name_ar) as full_name_ar, employee_number FROM hr_employee_profiles WHERE is_archived = 0 AND employment_status = 'active' ORDER BY first_name_ar, last_name_ar");
$types = $db->select("SELECT * FROM hr_overtime_types WHERE is_active = 1");
return $this->view('HR.Views.overtime.form', [
......@@ -67,7 +68,7 @@ class OvertimeController extends Controller
$this->authorize('hr.overtime.create');
$data = [
'employee_id' => (int) $request->post('employee_id'),
'employee_profile_id' => (int) $request->post('employee_profile_id'),
'overtime_type_id' => (int) $request->post('overtime_type_id'),
'request_date' => $request->post('request_date'),
'start_time' => $request->post('start_time'),
......
......@@ -17,7 +17,7 @@ class HrDepartment extends Model
protected static array $fillable = [
'department_code', 'name_ar', 'name_en', 'parent_id', 'manager_employee_id',
'branch_id', 'description_ar', 'is_active', 'sort_order',
'branch_id', 'description_ar', 'is_active', 'sort_order', 'staffing_capacity',
];
public static function allActive(): array
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Models;
use App\Core\Model;
use App\Core\App;
class HrDepartmentPosition extends Model
{
protected static string $table = 'hr_department_positions';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'department_id', 'job_title_id', 'authorized_count',
];
public static function getForDepartment(int $departmentId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT dp.*, jt.name_ar as job_title_name
FROM hr_department_positions dp
JOIN hr_job_titles jt ON jt.id = dp.job_title_id
WHERE dp.department_id = ?
ORDER BY jt.name_ar ASC",
[$departmentId]
);
}
public static function getStaffingReport(int $departmentId): array
{
$db = App::getInstance()->db();
$positions = $db->select(
"SELECT dp.id, dp.job_title_id, dp.authorized_count, jt.name_ar as job_title_name,
(SELECT COUNT(*) FROM hr_employee_profiles ep
WHERE ep.department_id = dp.department_id
AND ep.job_title_id = dp.job_title_id
AND ep.employment_status = 'active'
AND ep.is_archived = 0) as actual_count
FROM hr_department_positions dp
JOIN hr_job_titles jt ON jt.id = dp.job_title_id
WHERE dp.department_id = ?
ORDER BY jt.name_ar ASC",
[$departmentId]
);
return $positions;
}
}
......@@ -20,7 +20,7 @@ class HrEmployeeDocument extends Model
'original_filename', 'stored_filename', 'file_path',
'file_size', 'mime_type', 'expiry_date',
'is_verified', 'verified_by', 'verified_at',
'uploaded_by', 'notes',
'uploaded_by', 'notes', 'validity_status',
];
public static function getDocumentTypes(): array
......@@ -28,7 +28,7 @@ class HrEmployeeDocument extends Model
return [
'national_id' => 'بطاقة الرقم القومي',
'passport' => 'جواز سفر',
'birth_certificate'=> 'شهادة ميلاد',
'birth_certificate' => 'شهادة ميلاد',
'degree' => 'شهادة علمية',
'military_cert' => 'شهادة تأدية/إعفاء من الخدمة العسكرية',
'insurance_form1' => 'استمارة تأمينات 1',
......@@ -38,6 +38,8 @@ class HrEmployeeDocument extends Model
'employment_cert' => 'شهادة خبرة',
'contract_copy' => 'نسخة عقد',
'photo' => 'صورة شخصية',
'work_stub' => 'كعب العمل',
'insurance_certificate'=> 'شهادة تأمينية',
'other' => 'أخرى',
];
}
......@@ -85,6 +87,10 @@ class HrEmployeeDocument extends Model
$where .= ' AND d.is_verified = ?';
$params[] = (int) $filters['is_verified'];
}
if (!empty($filters['validity_status'])) {
$where .= ' AND d.validity_status = ?';
$params[] = $filters['validity_status'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_documents d WHERE {$where}",
......
......@@ -21,6 +21,7 @@ class HrEmployeeLoan extends Model
'request_date', 'start_deduction_date', 'reason',
'status', 'workflow_instance_id',
'approved_by', 'approved_at', 'disbursed_date', 'notes',
'external_entity_name', 'external_reference', 'monthly_salary_impact',
];
public static function getLoanTypes(): array
......@@ -29,6 +30,8 @@ class HrEmployeeLoan extends Model
'salary_advance' => 'سلفة راتب',
'personal_loan' => 'قرض شخصي',
'emergency_loan' => 'سلفة طوارئ',
'financing_company' => 'شركة تمويل',
'bank_loan' => 'قرض بنكي',
];
}
......
......@@ -16,13 +16,15 @@ class HrEmployeeProfile extends Model
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'employee_id', 'employee_number', 'national_id',
'employee_id', 'employee_number', 'fingerprint_code', 'national_id',
'first_name_ar', 'last_name_ar', 'first_name_en', 'last_name_en',
'date_of_birth', 'gender', 'marital_status', 'religion', 'nationality',
'phone', 'email', 'address',
'education_qualification', 'education_specialization',
'education_graduation_year', 'education_grade', 'education_university',
'department_id', 'job_title_id', 'direct_manager_id', 'salary_structure_id',
'hire_date', 'probation_end_date', 'probation_status',
'employment_type', 'employment_status',
'employment_type', 'workforce_type', 'employment_status',
'basic_salary', 'insurable_salary',
'bank_name', 'bank_account_number', 'bank_iban',
'insurance_number', 'insurance_start_date', 'tax_card_number',
......@@ -82,10 +84,20 @@ class HrEmployeeProfile extends Model
public static function getMilitaryStatuses(): array
{
return [
'completed' => 'أدى الخدمة',
'exempted' => 'معفى',
'postponed' => 'مؤجل',
'not_applicable' => 'لا ينطبق',
'final_exemption' => 'إعفاء نهائي',
'temporary_exemption' => 'إعفاء مؤقت',
'served' => 'أدى الخدمة',
'not_called' => 'لم يصبه الدور',
'medical_unfitness' => 'عدم اللياقة الطبية',
'female' => 'أنثى',
];
}
public static function getWorkforceTypes(): array
{
return [
'insured' => 'مؤمن عليه',
'part_time_freelance' => 'غير متفرغ',
];
}
......@@ -172,4 +184,35 @@ class HrEmployeeProfile extends Model
$years = $diff->y + ($diff->m / 12) + ($diff->d / 365);
return number_format($years, 2, '.', '');
}
public static function getComprehensiveSalary(int $profileId): string
{
$db = App::getInstance()->db();
$profile = $db->selectOne(
"SELECT basic_salary FROM hr_employee_profiles WHERE id = ?",
[$profileId]
);
if (!$profile) {
return '0.00';
}
$basicSalary = $profile['basic_salary'] ?? '0.00';
$earningsSum = $db->selectOne(
"SELECT COALESCE(SUM(
CASE
WHEN sd.calculation_type = 'fixed' THEN sd.amount
WHEN sd.calculation_type = 'percentage' THEN (? * sd.amount / 100)
ELSE 0
END
), 0) as total
FROM hr_employee_salary_details sd
JOIN hr_salary_components sc ON sc.id = sd.component_id
WHERE sd.employee_profile_id = ? AND sc.component_type = 'earning' AND sd.is_active = 1",
[$basicSalary, $profileId]
);
$earnings = $earningsSum['total'] ?? '0.00';
return bcadd($basicSalary, (string) $earnings, 2);
}
}
......@@ -17,7 +17,8 @@ class HrJobTitle extends Model
protected static array $fillable = [
'title_code', 'name_ar', 'name_en', 'grade_level', 'min_salary',
'max_salary', 'description_ar', 'is_active',
'max_salary', 'description_ar', 'is_active', 'job_description_ar',
'job_description_en',
];
public static function allActive(): array
......
......@@ -10,6 +10,9 @@ return [
['GET', '/hr/departments/{id:\d+}/edit', 'HR\Controllers\DepartmentController@edit', ['auth'], 'hr.department.manage'],
['POST', '/hr/departments/{id:\d+}', 'HR\Controllers\DepartmentController@update', ['auth', 'csrf'], 'hr.department.manage'],
['POST', '/hr/departments/{id:\d+}/archive', 'HR\Controllers\DepartmentController@archive', ['auth', 'csrf'], 'hr.department.manage'],
['GET', '/hr/departments/{id:\d+}/positions', 'HR\Controllers\DepartmentController@positions', ['auth'], 'hr.department.manage'],
['POST', '/hr/departments/{id:\d+}/positions', 'HR\Controllers\DepartmentController@storePosition', ['auth', 'csrf'], 'hr.department.manage'],
['POST', '/hr/departments/{id:\d+}/positions/{posId:\d+}/delete', 'HR\Controllers\DepartmentController@deletePosition', ['auth', 'csrf'], 'hr.department.manage'],
// ── Job Titles ──
['GET', '/hr/job-titles', 'HR\Controllers\JobTitleController@index', ['auth'], 'hr.job_title.view'],
......@@ -40,6 +43,7 @@ return [
['POST', '/hr/contracts/{id:\d+}', 'HR\Controllers\ContractController@update', ['auth', 'csrf'], 'hr.contract.manage'],
['POST', '/hr/contracts/{id:\d+}/renew', 'HR\Controllers\ContractController@renew', ['auth', 'csrf'], 'hr.contract.manage'],
['POST', '/hr/contracts/{id:\d+}/terminate', 'HR\Controllers\ContractController@terminate', ['auth', 'csrf'], 'hr.contract.manage'],
['GET', '/hr/contracts/{id:\d+}/work-receipt', 'HR\Controllers\ContractController@workReceipt', ['auth'], 'hr.contract.view'],
// ── Salary Structures ──
['GET', '/hr/salary-structures', 'HR\Controllers\SalaryStructureController@index', ['auth'], 'hr.employee.view_salary'],
......@@ -90,6 +94,7 @@ return [
['GET', '/hr/insurance/period/{periodId:\d+}', 'HR\Controllers\InsuranceController@periodRecords', ['auth'], 'hr.insurance.view'],
['GET', '/hr/insurance/employee/{employeeId:\d+}', 'HR\Controllers\InsuranceController@employeeHistory', ['auth'], 'hr.insurance.view'],
['GET', '/hr/insurance/form1', 'HR\Controllers\InsuranceController@form1', ['auth'], 'hr.insurance.manage'],
['GET', '/hr/insurance/form2', 'HR\Controllers\InsuranceController@form2', ['auth'], 'hr.insurance.view'],
['GET', '/hr/insurance/form6', 'HR\Controllers\InsuranceController@form6', ['auth'], 'hr.insurance.manage'],
// ── Tax ──
......@@ -179,6 +184,17 @@ return [
['POST', '/hr/permissions/{id:\d+}/approve', 'HR\Controllers\PermissionRequestController@approve', ['auth', 'csrf'], 'hr.permissions.approve'],
['POST', '/hr/permissions/{id:\d+}/reject', 'HR\Controllers\PermissionRequestController@reject', ['auth', 'csrf'], 'hr.permissions.approve'],
// ── Biometric ──
['GET', '/hr/biometric/devices', 'HR\Controllers\BiometricController@devices', ['auth'], 'hr.biometric.view'],
['GET', '/hr/biometric/devices/create', 'HR\Controllers\BiometricController@createDevice', ['auth'], 'hr.biometric.manage'],
['POST', '/hr/biometric/devices', 'HR\Controllers\BiometricController@storeDevice', ['auth', 'csrf'], 'hr.biometric.manage'],
['GET', '/hr/biometric/devices/{id:\d+}/edit', 'HR\Controllers\BiometricController@editDevice', ['auth'], 'hr.biometric.manage'],
['POST', '/hr/biometric/devices/{id:\d+}', 'HR\Controllers\BiometricController@updateDevice', ['auth', 'csrf'], 'hr.biometric.manage'],
['POST', '/hr/biometric/devices/{id:\d+}/delete', 'HR\Controllers\BiometricController@deleteDevice', ['auth', 'csrf'], 'hr.biometric.manage'],
['GET', '/hr/biometric/punches', 'HR\Controllers\BiometricController@punches', ['auth'], 'hr.biometric.view'],
['POST', '/hr/biometric/receive-punch', 'HR\Controllers\BiometricController@receivePunch', [], null],
['POST', '/hr/biometric/process-all', 'HR\Controllers\BiometricController@processAll', ['auth', 'csrf'], 'hr.biometric.manage'],
// ── HR Reports ──
['GET', '/hr/reports', 'HR\Controllers\HrReportController@index', ['auth'], 'hr.report.view'],
['GET', '/hr/reports/headcount', 'HR\Controllers\HrReportController@headcount', ['auth'], 'hr.report.view'],
......
......@@ -108,10 +108,13 @@ final class AttendanceService
if (!empty($scheduleData['start_time'])) {
$expectedStart = new \DateTime($date . ' ' . $scheduleData['start_time']);
$tolerance = (int) ($scheduleData['late_tolerance_minutes'] ?? 15);
$breastfeedingTolerance = LeaveService::getBreastfeedingToleranceMinutes($profileId);
$totalTolerance = $tolerance + $breastfeedingTolerance;
$gracePeriod = clone $expectedStart;
$gracePeriod->modify("+{$tolerance} minutes");
$gracePeriod->modify("+{$totalTolerance} minutes");
if ($checkIn > $gracePeriod) {
$lateMinutes = (int) (($checkIn->getTimestamp() - $expectedStart->getTimestamp()) / 60);
$lateMinutes = max(0, $lateMinutes - $breastfeedingTolerance);
}
}
......
This diff is collapsed.
......@@ -27,22 +27,33 @@ final class LeaveService
{
public static function calculateAnnualEntitlement(array $profile): string
{
$db = App::getInstance()->db();
$hireDate = $profile['hire_date'] ?? null;
$daysUnder10 = '21.0';
$daysOver10 = '30.0';
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.annual_days_under_10y']);
if ($row && $row['config_value'] !== '') {
$daysUnder10 = number_format((float) $row['config_value'], 1, '.', '');
}
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.annual_days_over_10y']);
if ($row && $row['config_value'] !== '') {
$daysOver10 = number_format((float) $row['config_value'], 1, '.', '');
}
if (!$hireDate) {
return '21.0';
return $daysUnder10;
}
$yearsOfService = (float) HrEmployeeProfile::getYearsOfService($hireDate);
$age = self::calculateAge($profile['date_of_birth'] ?? '');
// Hazardous work: 45 days (would need a flag on profile or department)
// Age >= 50 or service >= 10 years: 30 days
// Default: 21 days
if ($yearsOfService >= 10 || $age >= 50) {
return '30.0';
return $daysOver10;
}
return '21.0';
return $daysUnder10;
}
public static function submitRequest(array $data): array
......@@ -81,18 +92,30 @@ final class LeaveService
$totalDays = '0.5';
}
// Validate against limits
if ($leaveType['max_days_per_request'] && bccomp($totalDays, $leaveType['max_days_per_request'], 1) > 0) {
return ['success' => false, 'error' => 'تجاوز الحد الأقصى لأيام الطلب الواحد (' . $leaveType['max_days_per_request'] . ' يوم)'];
$isUnpaid = ($leaveType['category'] === 'unpaid' || ($leaveType['is_paid'] ?? 1) == 0);
// Check system_config for unpaid max (0 = unlimited)
$unpaidMaxDays = 0;
if ($isUnpaid) {
$configRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.unpaid_max_days']);
$unpaidMaxDays = (int) ($configRow['config_value'] ?? 0);
}
// Validate against limits (skip for unlimited unpaid)
if (!$isUnpaid || $unpaidMaxDays > 0) {
$effectiveMax = $isUnpaid ? (string) $unpaidMaxDays : $leaveType['max_days_per_request'];
if ($effectiveMax && bccomp($totalDays, $effectiveMax, 1) > 0) {
return ['success' => false, 'error' => 'تجاوز الحد الأقصى لأيام الطلب الواحد (' . $effectiveMax . ' يوم)'];
}
}
if ($leaveType['min_days_per_request'] && bccomp($totalDays, $leaveType['min_days_per_request'], 1) < 0) {
return ['success' => false, 'error' => 'الحد الأدنى للطلب هو ' . $leaveType['min_days_per_request'] . ' يوم'];
}
// Check balance
// Check balance (skip for unpaid leave — unlimited by default)
$year = (int) date('Y', strtotime($startDate));
$balance = HrLeaveBalance::getBalance($profileId, $leaveTypeId, $year);
if ($balance) {
if ($balance && !$isUnpaid) {
$remaining = bcsub(
bcadd(bcadd($balance['entitled_days'], $balance['carried_over_days'], 1), $balance['adjustment_days'], 1),
bcadd($balance['used_days'], $balance['pending_days'], 1),
......@@ -287,6 +310,8 @@ final class LeaveService
$entitled = $lt['default_days_per_year'];
if ($lt['code'] === 'annual') {
$entitled = $annualEntitlement;
} elseif ($lt['code'] === 'maternity') {
$entitled = self::getMaternityDays();
}
// Calculate carry-over from previous year
......@@ -342,6 +367,64 @@ final class LeaveService
return number_format($days, 1, '.', '');
}
public static function getMaternityDays(): string
{
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.maternity_days']);
return ($row && $row['config_value'] !== '') ? $row['config_value'] : '90';
}
public static function getMaternityMaxCareer(): int
{
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.maternity_max_career']);
return ($row && $row['config_value'] !== '') ? (int) $row['config_value'] : 3;
}
public static function getBreastfeedingDailyHours(): string
{
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.breastfeeding_daily_hours']);
return ($row && $row['config_value'] !== '') ? $row['config_value'] : '1';
}
public static function isEligibleForBreastfeeding(int $profileId): bool
{
$db = App::getInstance()->db();
$profile = HrEmployeeProfile::find($profileId);
if (!$profile || ($profile->gender ?? '') !== 'female') {
return false;
}
$lastMaternity = $db->selectOne(
"SELECT lr.end_date FROM hr_leave_requests lr
JOIN hr_leave_types lt ON lt.id = lr.leave_type_id
WHERE lr.employee_profile_id = ? AND lt.code = 'maternity' AND lr.status = 'approved' AND lr.is_archived = 0
ORDER BY lr.end_date DESC LIMIT 1",
[$profileId]
);
if (!$lastMaternity) {
return false;
}
$endDate = new \DateTime($lastMaternity['end_date']);
$now = new \DateTime();
$monthsSinceReturn = (int) $endDate->diff($now)->m + ((int) $endDate->diff($now)->y * 12);
// Egyptian law: breastfeeding hour for 24 months after birth
return $monthsSinceReturn <= 24;
}
public static function getBreastfeedingToleranceMinutes(int $profileId): int
{
if (!self::isEligibleForBreastfeeding($profileId)) {
return 0;
}
$hours = (float) self::getBreastfeedingDailyHours();
return (int) ($hours * 60);
}
private static function calculateAge(string $dob): int
{
if (empty($dob)) return 0;
......
......@@ -299,11 +299,75 @@ final class PayrollCalculationService
'sort_order' => $sortOrder,
];
// ── Step 8a: Martyrs Fund deduction ──
$martyrsFundRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.martyrs_fund_amount']);
$martyrsFundDeduction = $martyrsFundRow ? $martyrsFundRow['config_value'] : '0.00';
$sortOrder++;
$componentLog[] = [
'component_code' => 'MARTYRS_FUND_DED',
'name_ar' => 'صندوق الشهداء',
'type' => 'deduction',
'amount' => $martyrsFundDeduction,
'sort_order' => $sortOrder,
];
// ── Step 8b: Regular Stamp Duty deduction ──
$regularStampRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.regular_stamp_duty']);
$regularStampDeduction = $regularStampRow ? $regularStampRow['config_value'] : '0.00';
$sortOrder++;
$componentLog[] = [
'component_code' => 'REGULAR_STAMP_DED',
'name_ar' => 'الدمغة العادية',
'type' => 'deduction',
'amount' => $regularStampDeduction,
'sort_order' => $sortOrder,
];
// ── Step 8c: Additional Stamp Duty deduction ──
$additionalStampRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.additional_stamp_duty']);
$additionalStampDeduction = $additionalStampRow ? $additionalStampRow['config_value'] : '0.00';
$sortOrder++;
$componentLog[] = [
'component_code' => 'ADDITIONAL_STAMP_DED',
'name_ar' => 'الدمغة الإضافية',
'type' => 'deduction',
'amount' => $additionalStampDeduction,
'sort_order' => $sortOrder,
];
// ── Step 8d: External Loan Deduction (financing companies & bank loans) ──
$externalLoanRow = $db->selectOne(
"SELECT COALESCE(SUM(monthly_salary_impact), 0) AS total_external FROM hr_employee_loans WHERE employee_profile_id = ? AND loan_type IN ('financing_company', 'bank_loan') AND status IN ('approved', 'disbursed', 'repaying') AND is_archived = 0",
[$profileId]
);
$externalLoanDeduction = $externalLoanRow ? $externalLoanRow['total_external'] : '0.00';
$sortOrder++;
$componentLog[] = [
'component_code' => 'EXTERNAL_LOAN_DED',
'name_ar' => 'خصم قروض خارجية',
'type' => 'deduction',
'amount' => $externalLoanDeduction,
'sort_order' => $sortOrder,
];
// ── Step 9: Net salary ──
$otherDeductions = bcadd(
bcadd(bcadd($martyrsFundDeduction, $regularStampDeduction, 2), $additionalStampDeduction, 2),
$externalLoanDeduction,
2
);
$totalDeductions = bcadd(
bcadd(
bcadd(bcadd(bcadd($insuranceEmployee, $taxAmount, 2), $loanDeduction, 2), $penaltyDeduction, 2),
$absenceDeduction,
2
),
$otherDeductions,
2
);
$netSalary = bcsub($grossEarnings, $totalDeductions, 2);
if (bccomp($netSalary, '0', 2) < 0) {
......@@ -337,7 +401,7 @@ final class PayrollCalculationService
'loan_deduction' => $loanDeduction,
'penalty_deduction' => $penaltyDeduction,
'absence_deduction' => $absenceDeduction,
'other_deductions' => '0.00',
'other_deductions' => $otherDeductions,
'net_salary' => $netSalary,
'working_days' => $workingDays,
'present_days' => $presentDays,
......
<?php $__template->layout('Layout.main'); ?>
<?php $isEdit = $device !== null; ?>
<?php $__template->section('title'); ?><?= $isEdit ? 'تعديل جهاز البصمة' : 'إضافة جهاز بصمة' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?><a href="/hr/biometric/devices" class="btn btn-secondary">رجوع</a><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="<?= $isEdit ? '/hr/biometric/devices/' . (int) $device['id'] : '/hr/biometric/devices' ?>">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;">
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">اسم الجهاز <span style="color:#DC2626;">*</span></label>
<input type="text" name="device_name" value="<?= e(old('device_name') ?? ($isEdit ? $device['device_name'] : '')) ?>" class="form-control" required>
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">نوع الجهاز <span style="color:#DC2626;">*</span></label>
<select name="device_type" class="form-control">
<?php $currentType = old('device_type') ?? ($isEdit ? $device['device_type'] : 'fingerprint'); ?>
<option value="fingerprint" <?= $currentType === 'fingerprint' ? 'selected' : '' ?>>بصمة إصبع</option>
<option value="face" <?= $currentType === 'face' ? 'selected' : '' ?>>بصمة وجه</option>
<option value="card" <?= $currentType === 'card' ? 'selected' : '' ?>>كارت</option>
</select>
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">عنوان IP</label>
<input type="text" name="ip_address" value="<?= e(old('ip_address') ?? ($isEdit ? $device['ip_address'] : '')) ?>" class="form-control" placeholder="192.168.1.100">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">المنفذ (Port)</label>
<input type="number" name="port" value="<?= e(old('port') ?? ($isEdit ? $device['port'] : '')) ?>" class="form-control" min="1" max="65535" placeholder="4370">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الرقم التسلسلي</label>
<input type="text" name="serial_number" value="<?= e(old('serial_number') ?? ($isEdit ? $device['serial_number'] : '')) ?>" class="form-control">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">وصف الموقع</label>
<input type="text" name="location_description" value="<?= e(old('location_description') ?? ($isEdit ? $device['location_description'] : '')) ?>" class="form-control" placeholder="البوابة الرئيسية">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الفرع</label>
<select name="branch_id" class="form-control">
<option value="">-- بدون فرع --</option>
<?php $currentBranch = (int) (old('branch_id') ?? ($isEdit ? $device['branch_id'] : 0)); ?>
<?php foreach ($branches as $branch): ?>
<option value="<?= (int) $branch['id'] ?>" <?= $currentBranch === (int) $branch['id'] ? 'selected' : '' ?>><?= e($branch['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">
<input type="checkbox" name="is_active" value="1" <?= (old('is_active') ?? ($isEdit ? $device['is_active'] : 1)) ? 'checked' : '' ?>> فعال
</label>
</div>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<a href="/hr/biometric/devices" class="btn btn-secondary">إلغاء</a>
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'تحديث' : 'حفظ' ?></button>
</div>
</form>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>أجهزة البصمة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('hr.biometric.manage')): ?>
<a href="/hr/biometric/devices/create" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="plus" style="width:16px;height:16px;"></i> إضافة جهاز</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الاسم</th>
<th>النوع</th>
<th>IP</th>
<th>المنفذ</th>
<th>الرقم التسلسلي</th>
<th>الموقع</th>
<th>آخر مزامنة</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (empty($devices)): ?>
<tr><td colspan="9" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد أجهزة مسجلة</td></tr>
<?php else: ?>
<?php
$typeLabels = ['fingerprint' => 'بصمة إصبع', 'face' => 'بصمة وجه', 'card' => 'كارت'];
?>
<?php foreach ($devices as $device): ?>
<tr>
<td><?= e($device['device_name']) ?></td>
<td><?= e($typeLabels[$device['device_type']] ?? $device['device_type']) ?></td>
<td><?= e($device['ip_address'] ?? '-') ?></td>
<td><?= $device['port'] ? (int) $device['port'] : '-' ?></td>
<td><?= e($device['serial_number'] ?? '-') ?></td>
<td><?= e($device['location_description'] ?? '-') ?></td>
<td><?= $device['last_sync_at'] ? e($device['last_sync_at']) : '<span style="color:#9CA3AF;">لم تتم</span>' ?></td>
<td>
<?php if ($device['is_active']): ?>
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#DEF7EC;color:#03543F;">فعال</span>
<?php else: ?>
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#FDE8E8;color:#9B1C1C;">معطل</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<?php if (can('hr.biometric.manage')): ?>
<a href="/hr/biometric/devices/<?= (int) $device['id'] ?>/edit" style="color:#2563EB;margin-left:8px;" title="تعديل"><i data-lucide="edit" style="width:16px;height:16px;"></i></a>
<?php if ($device['is_active']): ?>
<form method="POST" action="/hr/biometric/devices/<?= (int) $device['id'] ?>/delete" style="display:inline;" onsubmit="return confirm('هل أنت متأكد من تعطيل هذا الجهاز؟')">
<?= csrf_field() ?>
<button type="submit" style="border:none;background:none;cursor:pointer;color:#DC2626;padding:0;" title="تعطيل"><i data-lucide="power-off" style="width:16px;height:16px;"></i></button>
</form>
<?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>if(typeof lucide!=='undefined')lucide.createIcons();</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل البصمات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('hr.biometric.manage')): ?>
<form method="POST" action="/hr/biometric/process-all" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:6px;" onclick="return confirm('هل تريد معالجة جميع البصمات المعلقة؟')"><i data-lucide="play" style="width:16px;height:16px;"></i> معالجة الكل</button>
</form>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;">
<form method="GET" action="/hr/biometric/punches" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;padding:16px;">
<div style="min-width:160px;">
<label style="display:block;margin-bottom:4px;font-size:13px;color:#6B7280;">من تاريخ</label>
<input type="date" name="date_from" value="<?= e($filters['date_from']) ?>" class="form-control">
</div>
<div style="min-width:160px;">
<label style="display:block;margin-bottom:4px;font-size:13px;color:#6B7280;">إلى تاريخ</label>
<input type="date" name="date_to" value="<?= e($filters['date_to']) ?>" class="form-control">
</div>
<div style="min-width:160px;">
<label style="display:block;margin-bottom:4px;font-size:13px;color:#6B7280;">الجهاز</label>
<select name="device_id" class="form-control">
<option value="">الكل</option>
<?php foreach ($devices as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= $filters['device_id'] == $d['id'] ? 'selected' : '' ?>><?= e($d['device_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:140px;">
<label style="display:block;margin-bottom:4px;font-size:13px;color:#6B7280;">حالة المعالجة</label>
<select name="processed" class="form-control">
<option value="">الكل</option>
<option value="0" <?= $filters['processed'] === '0' ? 'selected' : '' ?>>غير معالج</option>
<option value="1" <?= $filters['processed'] === '1' ? 'selected' : '' ?>>تمت المعالجة</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">بحث</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ والوقت</th>
<th>كود البصمة</th>
<th>اسم الموظف</th>
<th>نوع البصمة</th>
<th>الجهاز</th>
<th>حالة المعالجة</th>
</tr>
</thead>
<tbody>
<?php if (empty($punches)): ?>
<tr><td colspan="6" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد سجلات</td></tr>
<?php else: ?>
<?php
$punchTypeLabels = ['in' => 'دخول', 'out' => 'خروج', 'unknown' => 'غير محدد'];
?>
<?php foreach ($punches as $p): ?>
<tr>
<td><?= e($p['punch_time']) ?></td>
<td><code><?= e($p['fingerprint_code']) ?></code></td>
<td>
<?php if (!empty($p['first_name_ar'])): ?>
<?= e($p['first_name_ar'] . ' ' . $p['last_name_ar']) ?>
<small style="color:#6B7280;">(<?= e($p['employee_number']) ?>)</small>
<?php else: ?>
<span style="color:#DC2626;">غير معروف</span>
<?php endif; ?>
</td>
<td>
<?php
$typeColor = match($p['punch_type']) {
'in' => '#DEF7EC;color:#03543F',
'out' => '#DBEAFE;color:#1E40AF',
default => '#E5E7EB;color:#374151',
};
?>
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:<?= $typeColor ?>;"><?= e($punchTypeLabels[$p['punch_type']] ?? $p['punch_type']) ?></span>
</td>
<td><?= e($p['device_name']) ?></td>
<td>
<?php if ($p['processed']): ?>
<?php if ($p['error_message']): ?>
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#FEF3C7;color:#92400E;" title="<?= e($p['error_message']) ?>">خطأ</span>
<?php else: ?>
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#DEF7EC;color:#03543F;">تمت المعالجة</span>
<?php endif; ?>
<?php else: ?>
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#FDE8E8;color:#9B1C1C;">معلق</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (!empty($pagination) && $pagination['last_page'] > 1): ?>
<div style="padding:12px;border-top:1px solid #E5E7EB;display:flex;justify-content:center;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
</div>
<script>if(typeof lucide!=='undefined')lucide.createIcons();</script>
<?php $__template->endSection(); ?>
......@@ -12,13 +12,21 @@
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:16px;">بيانات العقد</h3></div>
<div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;">
<?php if (!$isEdit): ?>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الموظف <span style="color:#DC2626;">*</span></label>
<input type="hidden" name="employee_profile_id" value="<?= (int) ($profile ? $profile->id : old('employee_profile_id')) ?>">
<input type="text" class="form-control" value="<?= $profile ? e($profile->first_name_ar . ' ' . $profile->last_name_ar) : '' ?>" readonly>
<?php if ($isEdit): ?>
<input type="text" class="form-control" value="<?= $profile ? e($profile->first_name_ar . ' ' . $profile->last_name_ar) : '' ?>" readonly style="background:#f3f4f6;">
<?php elseif ($profile): ?>
<input type="hidden" name="employee_profile_id" value="<?= (int) $profile->id ?>">
<input type="text" class="form-control" value="<?= e($profile->first_name_ar . ' ' . $profile->last_name_ar) ?>" readonly style="background:#f3f4f6;">
<?php else: ?>
<input type="hidden" name="employee_profile_id" id="employee_profile_id" value="<?= (int) old('employee_profile_id') ?>">
<div style="position:relative;">
<input type="text" class="form-control" id="employee_search" placeholder="ابحث باسم الموظف..." autocomplete="off">
<div id="employee_results" style="position:absolute;top:100%;right:0;left:0;z-index:100;background:#fff;border:1px solid #ddd;border-top:none;max-height:200px;overflow-y:auto;display:none;"></div>
</div>
<?php endif; ?>
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">نوع العقد <span style="color:#DC2626;">*</span></label>
<select name="contract_type" class="form-control" required>
......@@ -71,4 +79,60 @@
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'تحديث' : 'حفظ' ?></button>
</div>
</form>
<?php if (!$isEdit && !$profile): ?>
<script>
(function() {
var searchInput = document.getElementById('employee_search');
var resultsDiv = document.getElementById('employee_results');
var hiddenInput = document.getElementById('employee_profile_id');
var timer = null;
if (!searchInput) return;
searchInput.addEventListener('input', function() {
clearTimeout(timer);
var q = this.value.trim();
if (q.length < 2) { resultsDiv.style.display = 'none'; return; }
timer = setTimeout(function() {
fetch('/hr/employees/search-json?q=' + encodeURIComponent(q))
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) {
resultsDiv.innerHTML = '<div style="padding:10px;color:#9CA3AF;text-align:center;">لا توجد نتائج</div>';
} else {
resultsDiv.innerHTML = data.map(function(emp) {
return '<div class="emp-option" data-id="' + emp.id + '" style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #f0f0f0;" onmouseover="this.style.background=\'#f3f4f6\'" onmouseout="this.style.background=\'#fff\'">'
+ escapeHtml(emp.first_name_ar + ' ' + emp.last_name_ar)
+ ' <small style="color:#6B7280;">(' + escapeHtml(emp.employee_number || '') + ')</small></div>';
}).join('');
}
resultsDiv.style.display = 'block';
});
}, 300);
});
resultsDiv.addEventListener('click', function(e) {
var option = e.target.closest('.emp-option');
if (option) {
hiddenInput.value = option.getAttribute('data-id');
searchInput.value = option.textContent.trim();
resultsDiv.style.display = 'none';
}
});
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
resultsDiv.style.display = 'none';
}
});
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
})();
</script>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إيصال استلام عمل<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<button onclick="window.print();" class="btn btn-primary no-print">طباعة</button>
<a href="/hr/contracts/<?= (int) $contract->id ?>" class="btn btn-secondary no-print">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<style>
@media print {
.no-print, .sidebar, .top-nav, .page-header, nav, header, footer { display: none !important; }
body { background: #fff !important; margin: 0; padding: 0; font-size: 14pt; }
.print-page { width: 210mm; margin: 0 auto; padding: 15mm; box-shadow: none !important; border: none !important; }
.card { border: none !important; box-shadow: none !important; }
}
.print-page { max-width: 210mm; margin: 0 auto; background: #fff; padding: 30px; direction: rtl; }
.receipt-header { text-align: center; margin-bottom: 40px; border-bottom: 2px solid #333; padding-bottom: 20px; }
.receipt-header h2 { margin: 0 0 10px; font-size: 22px; }
.receipt-header p { margin: 0; font-size: 14px; color: #555; }
.receipt-info { margin-bottom: 30px; }
.receipt-info table { width: 100%; border-collapse: collapse; }
.receipt-info table td { padding: 10px 15px; font-size: 14px; border-bottom: 1px solid #eee; }
.receipt-info table td:first-child { font-weight: 700; width: 150px; color: #333; }
.receipt-body { margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; line-height: 2.2; font-size: 15px; }
.signatures { display: flex; justify-content: space-between; margin-top: 60px; padding-top: 20px; }
.signatures .sig-block { text-align: center; min-width: 160px; }
.signatures .sig-block .sig-line { border-bottom: 1px solid #333; margin-top: 50px; margin-bottom: 8px; }
.signatures .sig-block .sig-label { font-size: 13px; font-weight: 700; }
.receipt-date { text-align: left; margin-top: 30px; font-size: 13px; color: #555; }
</style>
<div class="print-page">
<div class="receipt-header">
<h2>إيصال استلام عمل</h2>
<p>نموذج إقرار استلام عمل</p>
</div>
<div class="receipt-info">
<table>
<tr>
<td>اسم الموظف</td>
<td><?= e($employee->first_name_ar . ' ' . $employee->last_name_ar) ?></td>
</tr>
<tr>
<td>الرقم الوظيفي</td>
<td><?= e($employee->employee_number) ?></td>
</tr>
<tr>
<td>المسمى الوظيفي</td>
<td><?= e($jobTitle ?? '-') ?></td>
</tr>
<tr>
<td>القسم</td>
<td><?= e($department ?? '-') ?></td>
</tr>
<tr>
<td>تاريخ التعيين</td>
<td><?= e($employee->hire_date) ?></td>
</tr>
<tr>
<td>رقم العقد</td>
<td><?= e($contract->contract_number) ?></td>
</tr>
</table>
</div>
<div class="receipt-body">
أقر أنا / <strong><?= e($employee->first_name_ar . ' ' . $employee->last_name_ar) ?></strong>
بأنني استلمت العمل بوظيفة <strong><?= e($jobTitle ?? '___________') ?></strong>
بقسم <strong><?= e($department ?? '___________') ?></strong>
اعتبارًا من تاريخ <strong><?= e($contract->start_date) ?></strong>
وأتعهد بالالتزام بكافة اللوائح والأنظمة المعمول بها في المنشأة.
</div>
<div class="signatures">
<div class="sig-block">
<div class="sig-line"></div>
<div class="sig-label">توقيع الموظف</div>
</div>
<div class="sig-block">
<div class="sig-line"></div>
<div class="sig-label">توقيع المدير المباشر</div>
</div>
<div class="sig-block">
<div class="sig-line"></div>
<div class="sig-label">توقيع شئون العاملين</div>
</div>
</div>
<div class="receipt-date">
التاريخ: _____ / _____ / _________
</div>
</div>
<?php $__template->endSection(); ?>
......@@ -54,6 +54,10 @@
<?php endforeach; ?>
</select>
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">القوام المعتمد</label>
<input type="number" name="staffing_capacity" value="<?= e(old('staffing_capacity') ?? ($isEdit ? $department->staffing_capacity : '')) ?>" class="form-control" min="0" placeholder="0">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الحالة</label>
<select name="is_active" class="form-control">
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة القوام - <?= e($department->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/departments/<?= (int) $department->id ?>" class="btn btn-secondary">رجوع للقسم</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;display:flex;align-items:center;gap:8px;">
<i data-lucide="plus-circle" style="width:18px;height:18px;color:#6B7280;"></i> إضافة وظيفة معتمدة
</h3>
</div>
<div style="padding:16px;">
<form method="POST" action="/hr/departments/<?= (int) $department->id ?>/positions" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<?= csrf_field() ?>
<div style="flex:1;min-width:200px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">المسمى الوظيفي <span style="color:#DC2626;">*</span></label>
<select name="job_title_id" class="form-control" required>
<option value="">-- اختر --</option>
<?php foreach ($jobTitles as $jt): ?>
<option value="<?= (int) $jt['id'] ?>"><?= e($jt['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="width:150px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">العدد المعتمد <span style="color:#DC2626;">*</span></label>
<input type="number" name="authorized_count" class="form-control" min="1" value="1" required>
</div>
<button type="submit" class="btn btn-primary">حفظ</button>
</form>
</div>
</div>
<?php if (!empty($positions)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;">الوظائف المعتمدة (<?= count($positions) ?>)</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>المسمى الوظيفي</th>
<th>القوام المعتمد</th>
<th>القوة الفعلية</th>
<th>النواقص</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($positions as $pos): ?>
<?php
$authorized = (int) $pos['authorized_count'];
$actual = (int) $pos['actual_count'];
$deficit = $authorized - $actual;
?>
<tr>
<td><?= e($pos['job_title_name']) ?></td>
<td><?= $authorized ?></td>
<td><?= $actual ?></td>
<td>
<?php if ($deficit > 0): ?>
<span style="color:#DC2626;font-weight:600;"><?= $deficit ?></span>
<?php elseif ($deficit < 0): ?>
<span style="color:#059669;font-weight:600;"><?= abs($deficit) ?>+</span>
<?php else: ?>
<span style="color:#6B7280;">0</span>
<?php endif; ?>
</td>
<td>
<form method="POST" action="/hr/departments/<?= (int) $department->id ?>/positions/<?= (int) $pos['id'] ?>/delete" style="display:inline;" onsubmit="return confirm('هل أنت متأكد من حذف هذه الوظيفة؟');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-danger">حذف</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;text-align:center;color:#6B7280;">
لا توجد وظائف معتمدة لهذا القسم بعد
</div>
</div>
<?php endif; ?>
<script>if(typeof lucide!=='undefined')lucide.createIcons();</script>
<?php $__template->endSection(); ?>
......@@ -35,6 +35,84 @@
</div>
</div>
<?php if (!empty($positions)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;">
<h3 style="margin:0;font-size:16px;display:flex;align-items:center;gap:8px;">
<i data-lucide="users" style="width:18px;height:18px;color:#6B7280;"></i> القوام والقوة الفعلية
</h3>
<?php if (can('hr.department.manage')): ?>
<a href="/hr/departments/<?= (int) $department->id ?>/positions" class="btn btn-sm btn-primary">إدارة الوظائف</a>
<?php endif; ?>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>المسمى الوظيفي</th>
<th>القوام المعتمد</th>
<th>القوة الفعلية</th>
<th>النواقص</th>
</tr>
</thead>
<tbody>
<?php
$totalAuthorized = 0;
$totalActual = 0;
?>
<?php foreach ($positions as $pos): ?>
<?php
$authorized = (int) $pos['authorized_count'];
$actual = (int) $pos['actual_count'];
$deficit = $authorized - $actual;
$totalAuthorized += $authorized;
$totalActual += $actual;
?>
<tr>
<td><?= e($pos['job_title_name']) ?></td>
<td><?= $authorized ?></td>
<td><?= $actual ?></td>
<td>
<?php if ($deficit > 0): ?>
<span style="color:#DC2626;font-weight:600;"><?= $deficit ?></span>
<?php elseif ($deficit < 0): ?>
<span style="color:#059669;font-weight:600;"><?= abs($deficit) ?>+</span>
<?php else: ?>
<span style="color:#6B7280;">0</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;background:#F9FAFB;">
<td>الإجمالي</td>
<td><?= $totalAuthorized ?></td>
<td><?= $totalActual ?></td>
<td>
<?php $totalDeficit = $totalAuthorized - $totalActual; ?>
<?php if ($totalDeficit > 0): ?>
<span style="color:#DC2626;"><?= $totalDeficit ?></span>
<?php elseif ($totalDeficit < 0): ?>
<span style="color:#059669;"><?= abs($totalDeficit) ?>+</span>
<?php else: ?>
<span style="color:#6B7280;">0</span>
<?php endif; ?>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php elseif (can('hr.department.manage')): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;display:flex;align-items:center;justify-content:space-between;">
<span style="color:#6B7280;">لم يتم تحديد القوام المعتمد لهذا القسم بعد</span>
<a href="/hr/departments/<?= (int) $department->id ?>/positions" class="btn btn-sm btn-primary">إدارة الوظائف</a>
</div>
</div>
<?php endif; ?>
<?php if (!empty($children)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;">
......
......@@ -16,6 +16,7 @@
<div><label style="display:block;margin-bottom:4px;font-size:13px;">تاريخ الواقعة <span style="color:#DC2626;">*</span></label><input type="date" name="incident_date" value="<?= e(old('incident_date') ?? ($action->incident_date ?? date('Y-m-d'))) ?>" class="form-control" required></div>
<div style="grid-column:1/-1;"><label style="display:block;margin-bottom:4px;font-size:13px;">وصف الواقعة <span style="color:#DC2626;">*</span></label><textarea name="incident_description" class="form-control" rows="3" required><?= e(old('incident_description') ?? ($action->incident_description ?? '')) ?></textarea></div>
<div style="grid-column:1/-1;"><label style="display:block;margin-bottom:4px;font-size:13px;">ملاحظات التحقيق</label><textarea name="investigation_notes" class="form-control" rows="2"><?= e(old('investigation_notes') ?? ($action->investigation_notes ?? '')) ?></textarea></div>
<div style="grid-column:1/-1;"><label style="display:block;margin-bottom:4px;font-size:13px;">نتيجة التحقيق / الجزاء الموقع</label><textarea name="investigation_result" class="form-control" rows="2"><?= e(old('investigation_result') ?? ($action->investigation_result ?? '')) ?></textarea></div>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
......
......@@ -23,6 +23,12 @@
<div style="margin-top:4px;padding:12px;background:#F9FAFB;border-radius:6px;line-height:1.6;"><?= nl2br(e($action->investigation_notes)) ?></div>
</div>
<?php endif; ?>
<?php if ($action->investigation_result): ?>
<div style="padding:0 16px 16px;">
<span style="color:#6B7280;font-size:13px;">نتيجة التحقيق / الجزاء الموقع</span>
<div style="margin-top:4px;padding:12px;background:#F9FAFB;border-radius:6px;line-height:1.6;"><?= nl2br(e($action->investigation_result)) ?></div>
</div>
<?php endif; ?>
</div>
<?php if ($action->penalty_type): ?>
......
......@@ -15,16 +15,24 @@
<option value="1" <?= $filters['expiring_soon'] === '1' ? 'selected' : '' ?>>منتهية/قاربت على الانتهاء</option>
</select>
</div>
<div style="min-width:130px;"><label style="display:block;margin-bottom:4px;font-size:13px;color:#6B7280;">حالة الصلاحية</label>
<select name="validity_status" class="form-control">
<option value="">الكل</option>
<option value="valid" <?= ($filters['validity_status'] ?? '') === 'valid' ? 'selected' : '' ?>>سارية</option>
<option value="expiring_soon" <?= ($filters['validity_status'] ?? '') === 'expiring_soon' ? 'selected' : '' ?>>قاربت على الانتهاء</option>
<option value="expired" <?= ($filters['validity_status'] ?? '') === 'expired' ? 'selected' : '' ?>>منتهية</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">بحث</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>الموظف</th><th>نوع المستند</th><th>العنوان</th><th>تاريخ الانتهاء</th><th>التحقق</th><th>إجراءات</th></tr></thead>
<thead><tr><th>الموظف</th><th>نوع المستند</th><th>العنوان</th><th>تاريخ الانتهاء</th><th>حالة الصلاحية</th><th>التحقق</th><th>إجراءات</th></tr></thead>
<tbody>
<?php if (empty($documents)): ?>
<tr><td colspan="6" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد مستندات</td></tr>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد مستندات</td></tr>
<?php else: ?>
<?php foreach ($documents as $d): ?>
<?php $isExpired = !empty($d['expiry_date']) && $d['expiry_date'] < date('Y-m-d'); ?>
......@@ -34,6 +42,13 @@
<td><?= e($documentTypes[$d['document_type']] ?? $d['document_type']) ?></td>
<td><?= e($d['title_ar'] ?? '-') ?></td>
<td><?php if (!empty($d['expiry_date'])): ?><span style="<?= $isExpired ? 'color:#DC2626;font-weight:600;' : ($isExpiringSoon ? 'color:#D97706;font-weight:600;' : '') ?>"><?= e($d['expiry_date']) ?><?php if ($isExpired): ?> (منتهي)<?php elseif ($isExpiringSoon): ?> (قارب)<?php endif; ?></span><?php else: ?>-<?php endif; ?></td>
<td><?php
$vs = $d['validity_status'] ?? '';
if ($vs === 'valid'): ?><span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#DEF7EC;color:#03543F;">سارية</span><?php
elseif ($vs === 'expiring_soon'): ?><span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#FEF3C7;color:#92400E;">قاربت على الانتهاء</span><?php
elseif ($vs === 'expired'): ?><span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-size:12px;background:#FDE8E8;color:#9B1C1C;">منتهية</span><?php
else: ?>-<?php endif; ?>
</td>
<td><?php if ((int) ($d['is_verified'] ?? 0)): ?><span style="color:#059669;font-weight:600;">متحقق</span><?php else: ?><span style="color:#D97706;">غير متحقق</span><?php endif; ?></td>
<td><a href="/hr/documents/employee/<?= (int) $d['employee_profile_id'] ?>" style="color:#2563EB;"><i data-lucide="eye" style="width:16px;height:16px;"></i></a></td>
</tr>
......
This diff is collapsed.
......@@ -12,7 +12,7 @@
</div>
<div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;">
<div><span style="color:#6B7280;font-size:13px;">الراتب الأساسي</span><div style="font-weight:700;font-size:18px;"><?= number_format((float) $profile->basic_salary, 2) ?> ج.م</div></div>
<div><span style="color:#6B7280;font-size:13px;">الراتب التأميني</span><div style="font-weight:600;"><?= $profile->insurable_salary ? number_format((float) $profile->insurable_salary, 2) . ' ج.م' : 'مطابق للأساسي' ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الأجر التأميني</span><div style="font-weight:600;"><?= $profile->insurable_salary ? number_format((float) $profile->insurable_salary, 2) . ' ج.م' : 'مطابق للأساسي' ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">هيكل الرواتب</span><div><?= $structure ? e($structure->name_ar) : '<span style="color:#DC2626;">غير محدد</span>' ?></div></div>
</div>
</div>
......
......@@ -33,6 +33,8 @@ $sc = $statusColors[$profile->employment_status] ?? '#E5E7EB;color:#374151';
<div><span style="color:#6B7280;font-size:13px;">تاريخ الميلاد</span><div><?= e($profile->date_of_birth ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الجنس</span><div><?= e($genders[$profile->gender] ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الحالة الاجتماعية</span><div><?= e($maritalStatuses[$profile->marital_status] ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الجنسية</span><div><?= e($profile->nationality ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الموقف من التجنيد</span><div><?= e($militaryStatuses[$profile->military_status] ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الهاتف</span><div><?= e($profile->phone ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">البريد</span><div><?= e($profile->email ?? '-') ?></div></div>
</div>
......@@ -50,11 +52,32 @@ $sc = $statusColors[$profile->employment_status] ?? '#E5E7EB;color:#374151';
<div><span style="color:#6B7280;font-size:13px;">تاريخ التعيين</span><div><?= e($profile->hire_date) ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">سنوات الخدمة</span><div style="font-weight:600;"><?= number_format($yearsOfService, 1) ?> سنة</div></div>
<div><span style="color:#6B7280;font-size:13px;">نوع التوظيف</span><div><?= e($types[$profile->employment_type] ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">نوع العمالة</span><div><?= e($workforceTypes[$profile->workforce_type] ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">هيكل الرواتب</span><div><?= $structure ? e($structure->name_ar) : '-' ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">المدير المباشر</span><div><?= e($profile->direct_manager ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">كود البصمة</span><div><?= e($profile->fingerprint_code ?? '-') ?></div></div>
<?php if (can('hr.employee.view_salary')): ?>
<div><span style="color:#6B7280;font-size:13px;">الراتب الأساسي</span><div style="font-weight:600;"><?= number_format((float) $profile->basic_salary, 2) ?> ج.م</div></div>
<div><span style="color:#6B7280;font-size:13px;">الراتب الشامل</span><div style="font-weight:700;font-size:15px;color:#047857;"><?= number_format((float) ($comprehensiveSalary ?? 0), 2) ?> ج.م</div></div>
<div><span style="color:#6B7280;font-size:13px;">الأجر التأميني</span><div><?= number_format((float) ($profile->insurance_salary ?? 0), 2) ?> ج.م</div></div>
<?php endif; ?>
<div><span style="color:#6B7280;font-size:13px;">رقم التأمينات</span><div><?= e($profile->insurance_number ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الرقم التأميني</span><div><?= e($profile->insurance_number ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">تاريخ بداية التأمين</span><div><?= e($profile->insurance_start_date ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">رقم البطاقة الضريبية</span><div><?= e($profile->tax_card_number ?? '-') ?></div></div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;display:flex;align-items:center;gap:8px;">
<i data-lucide="graduation-cap" style="width:18px;height:18px;color:#6B7280;"></i> المؤهل الدراسي
</h3>
</div>
<div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;">
<div><span style="color:#6B7280;font-size:13px;">المؤهل</span><div><?= e($profile->education_level ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">التخصص</span><div><?= e($profile->education_specialization ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">الجامعة / المؤسسة التعليمية</span><div><?= e($profile->education_institution ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">سنة التخرج</span><div><?= e($profile->education_year ?? '-') ?></div></div>
</div>
</div>
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>نموذج 2 تأمينات - إخطار تعديل أجر<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<button onclick="window.print();" class="btn btn-primary no-print">طباعة</button>
<a href="/hr/insurance" class="btn btn-secondary no-print">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<style>
@media print {
.no-print, .sidebar, .top-nav, .page-header, nav, header, footer { display: none !important; }
body { background: #fff !important; margin: 0; padding: 0; font-size: 12pt; }
.print-page { width: 210mm; margin: 0 auto; padding: 10mm; box-shadow: none !important; border: none !important; }
.card { border: none !important; box-shadow: none !important; }
table { border-collapse: collapse; }
table th, table td { border: 1px solid #000 !important; padding: 6px 8px !important; }
}
.print-page { max-width: 210mm; margin: 0 auto; background: #fff; padding: 20px; }
.print-header { text-align: center; margin-bottom: 30px; }
.print-header h2 { margin: 0 0 5px; font-size: 18px; }
.print-header h3 { margin: 0 0 5px; font-size: 16px; }
.print-header h4 { margin: 0; font-size: 14px; font-weight: normal; }
.company-info { display: flex; justify-content: space-between; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; }
.company-info div { font-size: 13px; }
.form-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
.form-table th, .form-table td { border: 1px solid #333; padding: 8px 10px; text-align: center; font-size: 13px; }
.form-table th { background: #f3f4f6; font-weight: 700; }
.footer-section { display: flex; justify-content: space-between; margin-top: 40px; padding-top: 20px; }
.footer-section div { text-align: center; min-width: 150px; }
.footer-section .line { border-bottom: 1px solid #333; margin-top: 40px; margin-bottom: 5px; }
.filter-form { margin-bottom: 20px; }
</style>
<div class="no-print card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:16px;">تصفية حسب الفترة</h3></div>
<div style="padding:16px;">
<form method="GET" action="/hr/insurance/form2" class="filter-form" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الشهر</label>
<select name="month" class="form-control" style="min-width:100px;">
<?php for ($m = 1; $m <= 12; $m++): ?>
<option value="<?= $m ?>" <?= $m === $month ? 'selected' : '' ?>><?= $m ?></option>
<?php endfor; ?>
</select>
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">السنة</label>
<input type="number" name="year" value="<?= (int) $year ?>" class="form-control" style="width:100px;">
</div>
<div>
<button type="submit" class="btn btn-primary">عرض</button>
</div>
</form>
</div>
</div>
<div class="print-page">
<div class="print-header">
<h2>الهيئة القومية للتأمين الاجتماعي</h2>
<h3>نموذج رقم (2) تأمينات</h3>
<h4>إخطار بتعديل أجر مؤمن عليه</h4>
</div>
<div class="company-info">
<div><strong>اسم المنشأة:</strong> <?= e($companyName ?? 'النادي') ?></div>
<div><strong>رقم التأمينات:</strong> <?= e($insuranceNumber ?? '___________') ?></div>
<div><strong>الشهر:</strong> <?= e($month . '/' . $year) ?></div>
</div>
<table class="form-table">
<thead>
<tr>
<th style="width:40px;">م</th>
<th>اسم المؤمن عليه</th>
<th>الرقم التأميني</th>
<th>الرقم القومي</th>
<th>الأجر السابق</th>
<th>الأجر الجديد</th>
<th>تاريخ التعديل</th>
<th>سبب التعديل</th>
</tr>
</thead>
<tbody>
<?php if (empty($adjustments)): ?>
<tr><td colspan="8" style="padding:30px;color:#9CA3AF;">لا توجد تعديلات أجور في هذه الفترة</td></tr>
<?php else: ?>
<?php $i = 1; foreach ($adjustments as $adj): ?>
<tr>
<td><?= $i++ ?></td>
<td><?= e($adj['full_name'] ?? '') ?></td>
<td><?= e($adj['insurance_number'] ?? '-') ?></td>
<td><?= e($adj['national_id'] ?? '-') ?></td>
<td><?= number_format((float) ($adj['previous_basic_salary'] ?? 0), 2) ?></td>
<td><?= number_format((float) ($adj['new_basic_salary'] ?? 0), 2) ?></td>
<td><?= e($adj['effective_date'] ?? '-') ?></td>
<td><?= e($adj['reason'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<div class="footer-section">
<div>
<div class="line"></div>
<span>توقيع المسؤول</span>
</div>
<div>
<div class="line"></div>
<span>التاريخ</span>
</div>
<div>
<div class="line"></div>
<span>ختم المنشأة</span>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
......@@ -40,6 +40,10 @@
<label style="display:block;margin-bottom:4px;font-size:13px;">الوصف</label>
<textarea name="description_ar" class="form-control" rows="3"><?= e(old('description_ar') ?? ($isEdit ? $jobTitle->description_ar : '')) ?></textarea>
</div>
<div style="grid-column:1/-1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">التوصيف الوظيفي</label>
<textarea name="job_description_ar" class="form-control" rows="5" placeholder="المهام والمسؤوليات والمتطلبات..."><?= e(old('job_description_ar') ?? ($isEdit ? $jobTitle->job_description_ar : '')) ?></textarea>
</div>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
......
......@@ -12,16 +12,25 @@
</select>
</div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">نوع السلفة <span style="color:#DC2626;">*</span></label>
<select name="loan_type" class="form-control" required>
<select name="loan_type" id="loan_type_select" class="form-control" required>
<option value="salary_advance" <?= old('loan_type') === 'salary_advance' ? 'selected' : '' ?>>سلفة راتب</option>
<option value="personal_loan" <?= old('loan_type') === 'personal_loan' ? 'selected' : '' ?>>قرض شخصي</option>
<option value="emergency_loan" <?= old('loan_type') === 'emergency_loan' ? 'selected' : '' ?>>قرض طوارئ</option>
<option value="financing_company" <?= old('loan_type') === 'financing_company' ? 'selected' : '' ?>>شركة تمويل</option>
<option value="bank_loan" <?= old('loan_type') === 'bank_loan' ? 'selected' : '' ?>>قرض بنكي</option>
</select>
</div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">المبلغ <span style="color:#DC2626;">*</span></label><input type="number" name="loan_amount" value="<?= e(old('loan_amount') ?? '') ?>" class="form-control" step="0.01" min="0.01" placeholder="0.00" required></div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">عدد الأقساط <span style="color:#DC2626;">*</span></label><input type="number" name="number_of_installments" value="<?= e(old('number_of_installments') ?? '1') ?>" class="form-control" min="1" max="60" required></div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">تاريخ الطلب</label><input type="date" name="request_date" value="<?= e(old('request_date') ?? date('Y-m-d')) ?>" class="form-control"></div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">بداية الخصم <span style="color:#DC2626;">*</span></label><input type="date" name="start_deduction_date" value="<?= e(old('start_deduction_date') ?? date('Y-m-01', strtotime('+1 month'))) ?>" class="form-control" required></div>
<div id="external_fields" style="grid-column:1/-1;display:none;">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;padding-top:8px;border-top:1px dashed #E5E7EB;">
<div><label style="display:block;margin-bottom:4px;font-size:13px;">اسم الجهة</label><input type="text" name="external_entity_name" value="<?= e(old('external_entity_name') ?? '') ?>" class="form-control" placeholder="اسم البنك أو شركة التمويل"></div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">رقم المرجع</label><input type="text" name="external_reference" value="<?= e(old('external_reference') ?? '') ?>" class="form-control" placeholder="رقم العقد أو المرجع"></div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">المبلغ الشهري المستقطع من الراتب</label><input type="number" name="monthly_salary_impact" value="<?= e(old('monthly_salary_impact') ?? '') ?>" class="form-control" step="0.01" min="0" placeholder="0.00"></div>
</div>
</div>
<div style="grid-column:1/-1;"><label style="display:block;margin-bottom:4px;font-size:13px;">السبب</label><textarea name="reason" class="form-control" rows="2"><?= e(old('reason') ?? '') ?></textarea></div>
<div style="grid-column:1/-1;"><label style="display:block;margin-bottom:4px;font-size:13px;">ملاحظات</label><textarea name="notes" class="form-control" rows="2"><?= e(old('notes') ?? '') ?></textarea></div>
</div>
......@@ -31,4 +40,16 @@
<button type="submit" class="btn btn-primary">تقديم الطلب</button>
</div>
</form>
<script>
(function(){
var sel = document.getElementById('loan_type_select');
var extFields = document.getElementById('external_fields');
function toggle(){
var v = sel.value;
extFields.style.display = (v === 'financing_company' || v === 'bank_loan') ? '' : 'none';
}
sel.addEventListener('change', toggle);
toggle();
})();
</script>
<?php $__template->endSection(); ?>
......@@ -13,10 +13,10 @@
<div style="margin-bottom:15px;">
<label class="form-label">الموظف <span style="color:#DC2626;">*</span></label>
<select name="employee_id" class="form-select" required>
<select name="employee_profile_id" class="form-select" required>
<option value="">-- اختر الموظف --</option>
<?php foreach ($employees as $emp): ?>
<option value="<?= (int) $emp['id'] ?>" <?= old('employee_id') == $emp['id'] ? 'selected' : '' ?>><?= e($emp['name'] ?? '') ?></option>
<option value="<?= (int) $emp['id'] ?>" <?= old('employee_profile_id') == $emp['id'] ? 'selected' : '' ?>><?= e($emp['full_name_ar'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
......
......@@ -98,6 +98,10 @@ PermissionRegistry::register('hr', [
'hr.permissions.view' => ['ar' => 'عرض طلبات الأذونات', 'en' => 'View Permission Requests'],
'hr.permissions.create' => ['ar' => 'تقديم طلب إذن', 'en' => 'Submit Permission Request'],
'hr.permissions.approve' => ['ar' => 'اعتماد طلبات الأذونات', 'en' => 'Approve Permission Requests'],
// Biometric
'hr.biometric.view' => ['ar' => 'عرض أجهزة وبصمات الحضور', 'en' => 'View Biometric Devices & Punches'],
'hr.biometric.manage' => ['ar' => 'إدارة أجهزة البصمة', 'en' => 'Manage Biometric Devices'],
]);
// ────────────────────────────────────────────────────────────
......@@ -133,7 +137,8 @@ MenuRegistry::register('hr', [
['label_ar' => 'الورديات', 'label_en' => 'Shifts', 'route' => '/hr/shifts', 'permission' => 'hr.shifts.view', 'order' => 18],
['label_ar' => 'طلبات الأذونات', 'label_en' => 'Permissions', 'route' => '/hr/permissions', 'permission' => 'hr.permissions.view', 'order' => 19],
['label_ar' => 'المخالفات', 'label_en' => 'Violations', 'route' => '/hr/shifts/violations', 'permission' => 'hr.attendance.view', 'order' => 20],
['label_ar' => 'تقارير الموارد البشرية','label_en' => 'HR Reports', 'route' => '/hr/reports', 'permission' => 'hr.report.view', 'order' => 21],
['label_ar' => 'أجهزة البصمة', 'label_en' => 'Biometric', 'route' => '/hr/biometric/devices', 'permission' => 'hr.biometric.view', 'order' => 21],
['label_ar' => 'تقارير الموارد البشرية','label_en' => 'HR Reports', 'route' => '/hr/reports', 'permission' => 'hr.report.view', 'order' => 22],
],
]);
......
This diff is collapsed.
# Cross-Module Dependency Graph
> **Generated:** 2026-06-11
> **Generated:** 2026-07-28
> **Source:** 75 architecture maps in `/docs/architecture-maps/`
> **Purpose:** Single source of truth for cross-module relationships
......@@ -137,7 +137,7 @@
| `hr.loan.approved` | HR/LoanService | HR/bootstrap (SMS) |
| `hr.contract.renewed` | HR/ContractService | (no external listeners) |
| `hr.contract.terminated` | HR/ContractService | (no external listeners) |
| `hr.attendance.recorded` | HR | HR/bootstrap (auto-detect violations) |
| `hr.attendance.recorded` | HR/AttendanceService, HR/BiometricService | HR/bootstrap (auto-detect violations) |
| `attendance.violations_detected` | HR/AttendanceViolationService | (no external listeners) |
### 2.6 Fines & Violations Events
......
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