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
<?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\Services\BiometricService;
class BiometricController extends Controller
{
/**
* List all biometric devices with last_sync_at.
*/
public function devices(Request $request): Response
{
$this->authorize('hr.biometric.view');
$db = App::getInstance()->db();
$devices = $db->select(
"SELECT d.*, b.name_ar as branch_name
FROM hr_biometric_devices d
LEFT JOIN branches b ON b.id = d.branch_id
ORDER BY d.is_active DESC, d.device_name ASC"
);
return $this->view('HR.Views.biometric.devices', [
'devices' => $devices,
]);
}
/**
* Show form to add a new device.
*/
public function createDevice(Request $request): Response
{
$this->authorize('hr.biometric.manage');
$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.biometric.device_form', [
'device' => null,
'branches' => $branches,
]);
}
/**
* Validate and insert a new biometric device.
*/
public function storeDevice(Request $request): Response
{
$this->authorize('hr.biometric.manage');
$data = $this->extractDeviceData($request);
$errors = $this->validateDeviceData($data);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/biometric/devices/create');
}
$db = App::getInstance()->db();
$db->insert('hr_biometric_devices', [
'device_name' => $data['device_name'],
'device_type' => $data['device_type'],
'ip_address' => $data['ip_address'] ?: null,
'port' => $data['port'] > 0 ? $data['port'] : null,
'serial_number' => $data['serial_number'] ?: null,
'location_description' => $data['location_description'] ?: null,
'branch_id' => $data['branch_id'] > 0 ? $data['branch_id'] : null,
'is_active' => $data['is_active'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->redirect('/hr/biometric/devices')->withSuccess('تم إضافة الجهاز بنجاح');
}
/**
* Show edit form for a device.
*/
public function editDevice(Request $request, int $id): Response
{
$this->authorize('hr.biometric.manage');
$db = App::getInstance()->db();
$device = $db->selectOne("SELECT * FROM hr_biometric_devices WHERE id = ?", [$id]);
if (!$device) {
return $this->redirect('/hr/biometric/devices')->withError('الجهاز غير موجود');
}
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('HR.Views.biometric.device_form', [
'device' => $device,
'branches' => $branches,
]);
}
/**
* Update a biometric device.
*/
public function updateDevice(Request $request, int $id): Response
{
$this->authorize('hr.biometric.manage');
$db = App::getInstance()->db();
$device = $db->selectOne("SELECT * FROM hr_biometric_devices WHERE id = ?", [$id]);
if (!$device) {
return $this->redirect('/hr/biometric/devices')->withError('الجهاز غير موجود');
}
$data = $this->extractDeviceData($request);
$errors = $this->validateDeviceData($data, $id);
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/hr/biometric/devices/' . $id . '/edit');
}
$db->update('hr_biometric_devices', [
'device_name' => $data['device_name'],
'device_type' => $data['device_type'],
'ip_address' => $data['ip_address'] ?: null,
'port' => $data['port'] > 0 ? $data['port'] : null,
'serial_number' => $data['serial_number'] ?: null,
'location_description' => $data['location_description'] ?: null,
'branch_id' => $data['branch_id'] > 0 ? $data['branch_id'] : null,
'is_active' => $data['is_active'],
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$id]);
return $this->redirect('/hr/biometric/devices')->withSuccess('تم تحديث بيانات الجهاز بنجاح');
}
/**
* Soft delete a device (set is_active = 0).
*/
public function deleteDevice(Request $request, int $id): Response
{
$this->authorize('hr.biometric.manage');
$db = App::getInstance()->db();
$device = $db->selectOne("SELECT * FROM hr_biometric_devices WHERE id = ?", [$id]);
if (!$device) {
return $this->redirect('/hr/biometric/devices')->withError('الجهاز غير موجود');
}
$db->update('hr_biometric_devices', [
'is_active' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$id]);
return $this->redirect('/hr/biometric/devices')->withSuccess('تم تعطيل الجهاز بنجاح');
}
/**
* List recent biometric punches with filters.
*/
public function punches(Request $request): Response
{
$this->authorize('hr.biometric.view');
$db = App::getInstance()->db();
$filters = [
'date_from' => trim((string) $request->get('date_from', date('Y-m-d', strtotime('-7 days')))),
'date_to' => trim((string) $request->get('date_to', date('Y-m-d'))),
'device_id' => (int) $request->get('device_id', 0),
'processed' => trim((string) $request->get('processed', '')),
];
$page = max(1, (int) $request->get('page', 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$where = "WHERE p.punch_time >= ? AND p.punch_time < DATE_ADD(?, INTERVAL 1 DAY)";
$params = [$filters['date_from'], $filters['date_to']];
if ($filters['device_id'] > 0) {
$where .= " AND p.device_id = ?";
$params[] = $filters['device_id'];
}
if ($filters['processed'] !== '') {
$where .= " AND p.processed = ?";
$params[] = (int) $filters['processed'];
}
// Count total
$countRow = $db->selectOne(
"SELECT COUNT(*) as total FROM hr_biometric_punches p $where",
$params
);
$total = (int) ($countRow['total'] ?? 0);
// Fetch page
$punches = $db->select(
"SELECT p.*, d.device_name,
hp.first_name_ar, hp.last_name_ar, hp.employee_number
FROM hr_biometric_punches p
JOIN hr_biometric_devices d ON d.id = p.device_id
LEFT JOIN hr_employee_profiles hp ON hp.fingerprint_code = p.fingerprint_code AND hp.is_archived = 0
$where
ORDER BY p.punch_time DESC
LIMIT $perPage OFFSET $offset",
$params
);
// Devices for filter dropdown
$devices = $db->select("SELECT id, device_name FROM hr_biometric_devices ORDER BY device_name");
$pagination = [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => (int) ceil($total / $perPage),
];
return $this->view('HR.Views.biometric.punches', [
'punches' => $punches,
'pagination' => $pagination,
'filters' => $filters,
'devices' => $devices,
]);
}
/**
* API endpoint to receive a punch from a biometric device.
* No auth middleware — device authentication via serial_number.
* Accepts JSON body: {device_serial, fingerprint_code, punch_time, punch_type}
*/
public function receivePunch(Request $request): Response
{
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body)) {
return $this->json(['success' => false, 'error' => 'Invalid JSON body']);
}
$result = BiometricService::processPunch($body);
return $this->json($result);
}
/**
* Manually trigger processing of all unprocessed punches.
*/
public function processAll(Request $request): Response
{
$this->authorize('hr.biometric.manage');
$result = BiometricService::processAllPending();
return $this->redirect('/hr/biometric/punches?processed=0')
->withSuccess("تمت معالجة البصمات: {$result['processed']} ناجح، {$result['failed']} فاشل");
}
// ─── Private helpers ─────────────────────────────────────────
private function extractDeviceData(Request $request): array
{
return [
'device_name' => trim((string) $request->post('device_name', '')),
'device_type' => trim((string) $request->post('device_type', 'fingerprint')),
'ip_address' => trim((string) $request->post('ip_address', '')),
'port' => (int) $request->post('port', 0),
'serial_number' => trim((string) $request->post('serial_number', '')),
'location_description' => trim((string) $request->post('location_description', '')),
'branch_id' => (int) $request->post('branch_id', 0),
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validateDeviceData(array $data, ?int $excludeId = null): array
{
$errors = [];
if ($data['device_name'] === '' || mb_strlen($data['device_name']) < 2) {
$errors[] = 'اسم الجهاز مطلوب';
}
if (!in_array($data['device_type'], ['fingerprint', 'face', 'card'], true)) {
$errors[] = 'نوع الجهاز غير صالح';
}
// Check serial_number uniqueness if provided
if ($data['serial_number'] !== '') {
$db = App::getInstance()->db();
$query = "SELECT id FROM hr_biometric_devices WHERE serial_number = ?";
$params = [$data['serial_number']];
if ($excludeId !== null) {
$query .= " AND id != ?";
$params[] = $excludeId;
}
$existing = $db->selectOne($query, $params);
if ($existing) {
$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);
}
}
...@@ -197,6 +197,42 @@ class ContractController extends Controller ...@@ -197,6 +197,42 @@ class ContractController extends Controller
return $this->redirect('/hr/contracts/' . $result['contract_id'])->withSuccess('تم تجديد العقد بنجاح'); 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 public function terminate(Request $request, string $id): Response
{ {
$contract = HrContract::find((int) $id); $contract = HrContract::find((int) $id);
......
...@@ -8,7 +8,9 @@ use App\Core\Request; ...@@ -8,7 +8,9 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\HR\Models\HrDepartment; use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Models\HrDepartmentPosition;
use App\Modules\HR\Models\HrEmployeeProfile; use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Models\HrJobTitle;
class DepartmentController extends Controller class DepartmentController extends Controller
{ {
...@@ -91,6 +93,8 @@ class DepartmentController extends Controller ...@@ -91,6 +93,8 @@ class DepartmentController extends Controller
[(int) $id] [(int) $id]
); );
$positions = HrDepartmentPosition::getStaffingReport((int) $id);
return $this->view('HR.Views.departments.show', [ return $this->view('HR.Views.departments.show', [
'department' => $department, 'department' => $department,
'parent' => $parent, 'parent' => $parent,
...@@ -98,6 +102,7 @@ class DepartmentController extends Controller ...@@ -98,6 +102,7 @@ class DepartmentController extends Controller
'branch' => $branch, 'branch' => $branch,
'children' => $children, 'children' => $children,
'employeeCount' => (int) ($employeeCount['cnt'] ?? 0), 'employeeCount' => (int) ($employeeCount['cnt'] ?? 0),
'positions' => $positions,
]); ]);
} }
...@@ -176,6 +181,90 @@ class DepartmentController extends Controller ...@@ -176,6 +181,90 @@ class DepartmentController extends Controller
return $this->redirect('/hr/departments')->withSuccess('تم حذف القسم بنجاح'); 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 private function extractData(Request $request): array
{ {
return [ return [
...@@ -185,6 +274,7 @@ class DepartmentController extends Controller ...@@ -185,6 +274,7 @@ class DepartmentController extends Controller
'parent_id' => ((int) $request->post('parent_id', 0)) ?: null, 'parent_id' => ((int) $request->post('parent_id', 0)) ?: null,
'manager_employee_id' => ((int) $request->post('manager_employee_id', 0)) ?: null, 'manager_employee_id' => ((int) $request->post('manager_employee_id', 0)) ?: null,
'branch_id' => ((int) $request->post('branch_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)), 'is_active' => (int) ($request->post('is_active', 1)),
]; ];
} }
......
...@@ -260,6 +260,7 @@ class DisciplinaryController extends Controller ...@@ -260,6 +260,7 @@ class DisciplinaryController extends Controller
'incident_date' => trim((string) $request->post('incident_date', '')), 'incident_date' => trim((string) $request->post('incident_date', '')),
'incident_description' => trim((string) $request->post('incident_description', '')), 'incident_description' => trim((string) $request->post('incident_description', '')),
'investigation_notes' => trim((string) $request->post('investigation_notes', '')) ?: null, '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 ...@@ -19,8 +19,12 @@ class EmployeeDocumentController extends Controller
'q' => trim((string) $request->get('q', '')), 'q' => trim((string) $request->get('q', '')),
'document_type' => trim((string) $request->get('document_type', '')), 'document_type' => trim((string) $request->get('document_type', '')),
'expiring_soon' => trim((string) $request->get('expiring_soon', '')), '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)); $page = max(1, (int) $request->get('page', 1));
$result = HrEmployeeDocument::search($filters, 25, $page); $result = HrEmployeeDocument::search($filters, 25, $page);
...@@ -32,6 +36,34 @@ class EmployeeDocumentController extends Controller ...@@ -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 public function byEmployee(Request $request, string $employeeId): Response
{ {
$profile = HrEmployeeProfile::find((int) $employeeId); $profile = HrEmployeeProfile::find((int) $employeeId);
......
...@@ -55,6 +55,8 @@ class EmployeeProfileController extends Controller ...@@ -55,6 +55,8 @@ class EmployeeProfileController extends Controller
ORDER BY e.full_name_ar ASC" ORDER BY e.full_name_ar ASC"
); );
$activeProfiles = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.employees.form', [ return $this->view('HR.Views.employees.form', [
'profile' => null, 'profile' => null,
'unlinked' => $unlinked, 'unlinked' => $unlinked,
...@@ -63,10 +65,12 @@ class EmployeeProfileController extends Controller ...@@ -63,10 +65,12 @@ class EmployeeProfileController extends Controller
'structures' => HrSalaryStructure::allActive(), 'structures' => HrSalaryStructure::allActive(),
'statuses' => HrEmployeeProfile::getStatuses(), 'statuses' => HrEmployeeProfile::getStatuses(),
'types' => HrEmployeeProfile::getEmploymentTypes(), 'types' => HrEmployeeProfile::getEmploymentTypes(),
'workforceTypes' => HrEmployeeProfile::getWorkforceTypes(),
'genders' => HrEmployeeProfile::getGenders(), 'genders' => HrEmployeeProfile::getGenders(),
'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(), 'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(),
'religions' => HrEmployeeProfile::getReligions(), 'religions' => HrEmployeeProfile::getReligions(),
'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(), 'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(),
'managers' => $activeProfiles,
]); ]);
} }
...@@ -145,6 +149,7 @@ class EmployeeProfileController extends Controller ...@@ -145,6 +149,7 @@ class EmployeeProfileController extends Controller
); );
$yearsOfService = HrEmployeeProfile::getYearsOfService($profile->hire_date ?? date('Y-m-d')); $yearsOfService = HrEmployeeProfile::getYearsOfService($profile->hire_date ?? date('Y-m-d'));
$comprehensiveSalary = HrEmployeeProfile::getComprehensiveSalary((int) $id);
return $this->view('HR.Views.employees.show', [ return $this->view('HR.Views.employees.show', [
'profile' => $profile, 'profile' => $profile,
...@@ -155,10 +160,13 @@ class EmployeeProfileController extends Controller ...@@ -155,10 +160,13 @@ class EmployeeProfileController extends Controller
'activeLoans' => $activeLoans, 'activeLoans' => $activeLoans,
'recentLeaves' => $recentLeaves, 'recentLeaves' => $recentLeaves,
'yearsOfService' => $yearsOfService, 'yearsOfService' => $yearsOfService,
'comprehensiveSalary' => $comprehensiveSalary,
'statuses' => HrEmployeeProfile::getStatuses(), 'statuses' => HrEmployeeProfile::getStatuses(),
'types' => HrEmployeeProfile::getEmploymentTypes(), 'types' => HrEmployeeProfile::getEmploymentTypes(),
'workforceTypes' => HrEmployeeProfile::getWorkforceTypes(),
'genders' => HrEmployeeProfile::getGenders(), 'genders' => HrEmployeeProfile::getGenders(),
'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(), 'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(),
'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(),
]); ]);
} }
...@@ -169,6 +177,8 @@ class EmployeeProfileController extends Controller ...@@ -169,6 +177,8 @@ class EmployeeProfileController extends Controller
return $this->redirect('/hr/employees')->withError('ملف الموظف غير موجود'); return $this->redirect('/hr/employees')->withError('ملف الموظف غير موجود');
} }
$activeProfiles = HrEmployeeProfile::getActiveIds();
return $this->view('HR.Views.employees.form', [ return $this->view('HR.Views.employees.form', [
'profile' => $profile, 'profile' => $profile,
'unlinked' => [], 'unlinked' => [],
...@@ -177,10 +187,12 @@ class EmployeeProfileController extends Controller ...@@ -177,10 +187,12 @@ class EmployeeProfileController extends Controller
'structures' => HrSalaryStructure::allActive(), 'structures' => HrSalaryStructure::allActive(),
'statuses' => HrEmployeeProfile::getStatuses(), 'statuses' => HrEmployeeProfile::getStatuses(),
'types' => HrEmployeeProfile::getEmploymentTypes(), 'types' => HrEmployeeProfile::getEmploymentTypes(),
'workforceTypes' => HrEmployeeProfile::getWorkforceTypes(),
'genders' => HrEmployeeProfile::getGenders(), 'genders' => HrEmployeeProfile::getGenders(),
'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(), 'maritalStatuses' => HrEmployeeProfile::getMaritalStatuses(),
'religions' => HrEmployeeProfile::getReligions(), 'religions' => HrEmployeeProfile::getReligions(),
'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(), 'militaryStatuses' => HrEmployeeProfile::getMilitaryStatuses(),
'managers' => $activeProfiles,
]); ]);
} }
...@@ -363,6 +375,7 @@ class EmployeeProfileController extends Controller ...@@ -363,6 +375,7 @@ class EmployeeProfileController extends Controller
return [ return [
'employee_id' => ((int) $request->post('employee_id', 0)) ?: null, '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', '')), 'first_name_ar' => trim((string) $request->post('first_name_ar', '')),
'last_name_ar' => trim((string) $request->post('last_name_ar', '')), 'last_name_ar' => trim((string) $request->post('last_name_ar', '')),
'first_name_en' => trim((string) $request->post('first_name_en', '')) ?: null, 'first_name_en' => trim((string) $request->post('first_name_en', '')) ?: null,
...@@ -372,19 +385,29 @@ class EmployeeProfileController extends Controller ...@@ -372,19 +385,29 @@ class EmployeeProfileController extends Controller
'gender' => $gender, 'gender' => $gender,
'marital_status' => trim((string) $request->post('marital_status', 'single')), 'marital_status' => trim((string) $request->post('marital_status', 'single')),
'religion' => trim((string) $request->post('religion', 'muslim')), 'religion' => trim((string) $request->post('religion', 'muslim')),
'nationality' => trim((string) $request->post('nationality', 'egyptian')) ?: 'egyptian',
'phone' => trim((string) $request->post('phone', '')) ?: null, 'phone' => trim((string) $request->post('phone', '')) ?: null,
'email' => trim((string) $request->post('email', '')) ?: null, 'email' => trim((string) $request->post('email', '')) ?: null,
'address' => trim((string) $request->post('address', '')) ?: 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, 'department_id' => ((int) $request->post('department_id', 0)) ?: null,
'job_title_id' => ((int) $request->post('job_title_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, 'salary_structure_id' => ((int) $request->post('salary_structure_id', 0)) ?: null,
'hire_date' => trim((string) $request->post('hire_date', '')), 'hire_date' => trim((string) $request->post('hire_date', '')),
'employment_type' => trim((string) $request->post('employment_type', 'full_time')), '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')), 'employment_status' => trim((string) $request->post('employment_status', 'active')),
'probation_end_date' => trim((string) $request->post('probation_end_date', '')) ?: null, 'probation_end_date' => trim((string) $request->post('probation_end_date', '')) ?: null,
'basic_salary' => trim((string) $request->post('basic_salary', '0.00')), 'basic_salary' => trim((string) $request->post('basic_salary', '0.00')),
'insurable_salary' => trim((string) $request->post('insurable_salary', '0.00')) ?: '0.00', 'insurable_salary' => trim((string) $request->post('insurable_salary', '0.00')) ?: '0.00',
'insurance_number' => trim((string) $request->post('insurance_number', '')) ?: null, '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_name' => trim((string) $request->post('bank_name', '')) ?: null,
'bank_account_number' => trim((string) $request->post('bank_account_number', '')) ?: null, 'bank_account_number' => trim((string) $request->post('bank_account_number', '')) ?: null,
'bank_iban' => trim((string) $request->post('bank_iban', '')) ?: null, 'bank_iban' => trim((string) $request->post('bank_iban', '')) ?: null,
......
...@@ -97,6 +97,52 @@ class InsuranceController extends Controller ...@@ -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 public function form6(Request $request): Response
{ {
$year = (int) $request->get('year', (int) date('Y')); $year = (int) $request->get('year', (int) date('Y'));
......
...@@ -111,7 +111,9 @@ class JobTitleController extends Controller ...@@ -111,7 +111,9 @@ class JobTitleController extends Controller
'grade_level' => ((int) $request->post('grade_level', 0)) ?: null, 'grade_level' => ((int) $request->post('grade_level', 0)) ?: null,
'min_salary' => trim((string) $request->post('min_salary', '')) ?: null, 'min_salary' => trim((string) $request->post('min_salary', '')) ?: null,
'max_salary' => trim((string) $request->post('max_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)), 'is_active' => (int) ($request->post('is_active', 1)),
]; ];
} }
......
...@@ -53,6 +53,9 @@ class LoanController extends Controller ...@@ -53,6 +53,9 @@ class LoanController extends Controller
'start_deduction_date' => trim((string) $request->post('start_deduction_date', '')), 'start_deduction_date' => trim((string) $request->post('start_deduction_date', '')),
'reason' => trim((string) $request->post('reason', '')) ?: null, 'reason' => trim((string) $request->post('reason', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: 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); $result = LoanService::create($data);
......
...@@ -27,10 +27,11 @@ class OvertimeController extends Controller ...@@ -27,10 +27,11 @@ class OvertimeController extends Controller
} }
$requests = $db->select( $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 ap.full_name_ar as approved_by_name
FROM hr_overtime_requests r 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 JOIN hr_overtime_types t ON t.id = r.overtime_type_id
LEFT JOIN employees ap ON ap.id = r.approved_by LEFT JOIN employees ap ON ap.id = r.approved_by
WHERE {$where} WHERE {$where}
...@@ -53,7 +54,7 @@ class OvertimeController extends Controller ...@@ -53,7 +54,7 @@ class OvertimeController extends Controller
$this->authorize('hr.overtime.create'); $this->authorize('hr.overtime.create');
$db = App::getInstance()->db(); $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"); $types = $db->select("SELECT * FROM hr_overtime_types WHERE is_active = 1");
return $this->view('HR.Views.overtime.form', [ return $this->view('HR.Views.overtime.form', [
...@@ -67,7 +68,7 @@ class OvertimeController extends Controller ...@@ -67,7 +68,7 @@ class OvertimeController extends Controller
$this->authorize('hr.overtime.create'); $this->authorize('hr.overtime.create');
$data = [ $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'), 'overtime_type_id' => (int) $request->post('overtime_type_id'),
'request_date' => $request->post('request_date'), 'request_date' => $request->post('request_date'),
'start_time' => $request->post('start_time'), 'start_time' => $request->post('start_time'),
......
...@@ -17,7 +17,7 @@ class HrDepartment extends Model ...@@ -17,7 +17,7 @@ class HrDepartment extends Model
protected static array $fillable = [ protected static array $fillable = [
'department_code', 'name_ar', 'name_en', 'parent_id', 'manager_employee_id', '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 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 ...@@ -20,7 +20,7 @@ class HrEmployeeDocument extends Model
'original_filename', 'stored_filename', 'file_path', 'original_filename', 'stored_filename', 'file_path',
'file_size', 'mime_type', 'expiry_date', 'file_size', 'mime_type', 'expiry_date',
'is_verified', 'verified_by', 'verified_at', 'is_verified', 'verified_by', 'verified_at',
'uploaded_by', 'notes', 'uploaded_by', 'notes', 'validity_status',
]; ];
public static function getDocumentTypes(): array public static function getDocumentTypes(): array
...@@ -28,7 +28,7 @@ class HrEmployeeDocument extends Model ...@@ -28,7 +28,7 @@ class HrEmployeeDocument extends Model
return [ return [
'national_id' => 'بطاقة الرقم القومي', 'national_id' => 'بطاقة الرقم القومي',
'passport' => 'جواز سفر', 'passport' => 'جواز سفر',
'birth_certificate'=> 'شهادة ميلاد', 'birth_certificate' => 'شهادة ميلاد',
'degree' => 'شهادة علمية', 'degree' => 'شهادة علمية',
'military_cert' => 'شهادة تأدية/إعفاء من الخدمة العسكرية', 'military_cert' => 'شهادة تأدية/إعفاء من الخدمة العسكرية',
'insurance_form1' => 'استمارة تأمينات 1', 'insurance_form1' => 'استمارة تأمينات 1',
...@@ -38,6 +38,8 @@ class HrEmployeeDocument extends Model ...@@ -38,6 +38,8 @@ class HrEmployeeDocument extends Model
'employment_cert' => 'شهادة خبرة', 'employment_cert' => 'شهادة خبرة',
'contract_copy' => 'نسخة عقد', 'contract_copy' => 'نسخة عقد',
'photo' => 'صورة شخصية', 'photo' => 'صورة شخصية',
'work_stub' => 'كعب العمل',
'insurance_certificate'=> 'شهادة تأمينية',
'other' => 'أخرى', 'other' => 'أخرى',
]; ];
} }
...@@ -85,6 +87,10 @@ class HrEmployeeDocument extends Model ...@@ -85,6 +87,10 @@ class HrEmployeeDocument extends Model
$where .= ' AND d.is_verified = ?'; $where .= ' AND d.is_verified = ?';
$params[] = (int) $filters['is_verified']; $params[] = (int) $filters['is_verified'];
} }
if (!empty($filters['validity_status'])) {
$where .= ' AND d.validity_status = ?';
$params[] = $filters['validity_status'];
}
$countRow = $db->selectOne( $countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_employee_documents d WHERE {$where}", "SELECT COUNT(*) as cnt FROM hr_employee_documents d WHERE {$where}",
......
...@@ -21,6 +21,7 @@ class HrEmployeeLoan extends Model ...@@ -21,6 +21,7 @@ class HrEmployeeLoan extends Model
'request_date', 'start_deduction_date', 'reason', 'request_date', 'start_deduction_date', 'reason',
'status', 'workflow_instance_id', 'status', 'workflow_instance_id',
'approved_by', 'approved_at', 'disbursed_date', 'notes', 'approved_by', 'approved_at', 'disbursed_date', 'notes',
'external_entity_name', 'external_reference', 'monthly_salary_impact',
]; ];
public static function getLoanTypes(): array public static function getLoanTypes(): array
...@@ -29,6 +30,8 @@ class HrEmployeeLoan extends Model ...@@ -29,6 +30,8 @@ class HrEmployeeLoan extends Model
'salary_advance' => 'سلفة راتب', 'salary_advance' => 'سلفة راتب',
'personal_loan' => 'قرض شخصي', 'personal_loan' => 'قرض شخصي',
'emergency_loan' => 'سلفة طوارئ', 'emergency_loan' => 'سلفة طوارئ',
'financing_company' => 'شركة تمويل',
'bank_loan' => 'قرض بنكي',
]; ];
} }
......
...@@ -16,13 +16,15 @@ class HrEmployeeProfile extends Model ...@@ -16,13 +16,15 @@ class HrEmployeeProfile extends Model
protected static bool $autoTrackAuthor = true; protected static bool $autoTrackAuthor = true;
protected static array $fillable = [ 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', 'first_name_ar', 'last_name_ar', 'first_name_en', 'last_name_en',
'date_of_birth', 'gender', 'marital_status', 'religion', 'nationality', 'date_of_birth', 'gender', 'marital_status', 'religion', 'nationality',
'phone', 'email', 'address', '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', 'department_id', 'job_title_id', 'direct_manager_id', 'salary_structure_id',
'hire_date', 'probation_end_date', 'probation_status', 'hire_date', 'probation_end_date', 'probation_status',
'employment_type', 'employment_status', 'employment_type', 'workforce_type', 'employment_status',
'basic_salary', 'insurable_salary', 'basic_salary', 'insurable_salary',
'bank_name', 'bank_account_number', 'bank_iban', 'bank_name', 'bank_account_number', 'bank_iban',
'insurance_number', 'insurance_start_date', 'tax_card_number', 'insurance_number', 'insurance_start_date', 'tax_card_number',
...@@ -82,10 +84,20 @@ class HrEmployeeProfile extends Model ...@@ -82,10 +84,20 @@ class HrEmployeeProfile extends Model
public static function getMilitaryStatuses(): array public static function getMilitaryStatuses(): array
{ {
return [ return [
'completed' => 'أدى الخدمة', 'final_exemption' => 'إعفاء نهائي',
'exempted' => 'معفى', 'temporary_exemption' => 'إعفاء مؤقت',
'postponed' => 'مؤجل', 'served' => 'أدى الخدمة',
'not_applicable' => 'لا ينطبق', 'not_called' => 'لم يصبه الدور',
'medical_unfitness' => 'عدم اللياقة الطبية',
'female' => 'أنثى',
];
}
public static function getWorkforceTypes(): array
{
return [
'insured' => 'مؤمن عليه',
'part_time_freelance' => 'غير متفرغ',
]; ];
} }
...@@ -172,4 +184,35 @@ class HrEmployeeProfile extends Model ...@@ -172,4 +184,35 @@ class HrEmployeeProfile extends Model
$years = $diff->y + ($diff->m / 12) + ($diff->d / 365); $years = $diff->y + ($diff->m / 12) + ($diff->d / 365);
return number_format($years, 2, '.', ''); 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 ...@@ -17,7 +17,8 @@ class HrJobTitle extends Model
protected static array $fillable = [ protected static array $fillable = [
'title_code', 'name_ar', 'name_en', 'grade_level', 'min_salary', '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 public static function allActive(): array
......
...@@ -10,6 +10,9 @@ return [ ...@@ -10,6 +10,9 @@ return [
['GET', '/hr/departments/{id:\d+}/edit', 'HR\Controllers\DepartmentController@edit', ['auth'], 'hr.department.manage'], ['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+}', 'HR\Controllers\DepartmentController@update', ['auth', 'csrf'], 'hr.department.manage'],
['POST', '/hr/departments/{id:\d+}/archive', 'HR\Controllers\DepartmentController@archive', ['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 ── // ── Job Titles ──
['GET', '/hr/job-titles', 'HR\Controllers\JobTitleController@index', ['auth'], 'hr.job_title.view'], ['GET', '/hr/job-titles', 'HR\Controllers\JobTitleController@index', ['auth'], 'hr.job_title.view'],
...@@ -40,6 +43,7 @@ return [ ...@@ -40,6 +43,7 @@ return [
['POST', '/hr/contracts/{id:\d+}', 'HR\Controllers\ContractController@update', ['auth', 'csrf'], 'hr.contract.manage'], ['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+}/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'], ['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 ── // ── Salary Structures ──
['GET', '/hr/salary-structures', 'HR\Controllers\SalaryStructureController@index', ['auth'], 'hr.employee.view_salary'], ['GET', '/hr/salary-structures', 'HR\Controllers\SalaryStructureController@index', ['auth'], 'hr.employee.view_salary'],
...@@ -90,6 +94,7 @@ return [ ...@@ -90,6 +94,7 @@ return [
['GET', '/hr/insurance/period/{periodId:\d+}', 'HR\Controllers\InsuranceController@periodRecords', ['auth'], 'hr.insurance.view'], ['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/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/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'], ['GET', '/hr/insurance/form6', 'HR\Controllers\InsuranceController@form6', ['auth'], 'hr.insurance.manage'],
// ── Tax ── // ── Tax ──
...@@ -179,6 +184,17 @@ return [ ...@@ -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+}/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'], ['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 ── // ── HR Reports ──
['GET', '/hr/reports', 'HR\Controllers\HrReportController@index', ['auth'], 'hr.report.view'], ['GET', '/hr/reports', 'HR\Controllers\HrReportController@index', ['auth'], 'hr.report.view'],
['GET', '/hr/reports/headcount', 'HR\Controllers\HrReportController@headcount', ['auth'], 'hr.report.view'], ['GET', '/hr/reports/headcount', 'HR\Controllers\HrReportController@headcount', ['auth'], 'hr.report.view'],
......
...@@ -108,10 +108,13 @@ final class AttendanceService ...@@ -108,10 +108,13 @@ final class AttendanceService
if (!empty($scheduleData['start_time'])) { if (!empty($scheduleData['start_time'])) {
$expectedStart = new \DateTime($date . ' ' . $scheduleData['start_time']); $expectedStart = new \DateTime($date . ' ' . $scheduleData['start_time']);
$tolerance = (int) ($scheduleData['late_tolerance_minutes'] ?? 15); $tolerance = (int) ($scheduleData['late_tolerance_minutes'] ?? 15);
$breastfeedingTolerance = LeaveService::getBreastfeedingToleranceMinutes($profileId);
$totalTolerance = $tolerance + $breastfeedingTolerance;
$gracePeriod = clone $expectedStart; $gracePeriod = clone $expectedStart;
$gracePeriod->modify("+{$tolerance} minutes"); $gracePeriod->modify("+{$totalTolerance} minutes");
if ($checkIn > $gracePeriod) { if ($checkIn > $gracePeriod) {
$lateMinutes = (int) (($checkIn->getTimestamp() - $expectedStart->getTimestamp()) / 60); $lateMinutes = (int) (($checkIn->getTimestamp() - $expectedStart->getTimestamp()) / 60);
$lateMinutes = max(0, $lateMinutes - $breastfeedingTolerance);
} }
} }
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\HR\Models\HrEmployeeProfile;
/**
* Biometric Service
*
* Handles:
* - Processing biometric punches from devices
* - Matching fingerprint codes to employees
* - Determining punch type (in/out) based on attendance state
* - Bulk processing of pending punches
*/
final class BiometricService
{
/**
* Process a single punch received from a biometric device.
*
* @param array $data {device_serial, fingerprint_code, punch_time, punch_type}
* @return array {success, punch_id?, error?}
*/
public static function processPunch(array $data): array
{
$db = App::getInstance()->db();
$deviceSerial = trim((string) ($data['device_serial'] ?? ''));
$fingerprintCode = trim((string) ($data['fingerprint_code'] ?? ''));
$punchTime = trim((string) ($data['punch_time'] ?? ''));
$punchType = trim((string) ($data['punch_type'] ?? 'unknown'));
if ($deviceSerial === '' || $fingerprintCode === '' || $punchTime === '') {
return ['success' => false, 'error' => 'Missing required fields'];
}
// Validate device exists and is active
$device = $db->selectOne(
"SELECT id FROM hr_biometric_devices WHERE serial_number = ? AND is_active = 1",
[$deviceSerial]
);
if (!$device) {
return ['success' => false, 'error' => 'Device not found or inactive'];
}
$deviceId = (int) $device['id'];
// Normalize punch_type
if (!in_array($punchType, ['in', 'out', 'unknown'], true)) {
$punchType = 'unknown';
}
// Insert punch record
$db->insert('hr_biometric_punches', [
'device_id' => $deviceId,
'fingerprint_code' => $fingerprintCode,
'punch_time' => $punchTime,
'punch_type' => $punchType,
'processed' => 0,
'created_at' => date('Y-m-d H:i:s'),
]);
$punchId = (int) $db->lastInsertId();
// Update device last_sync_at
$db->update('hr_biometric_devices', [
'last_sync_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$deviceId]);
// Attempt to match employee and record attendance
$profile = self::resolveEmployee($fingerprintCode);
if ($profile) {
$date = substr($punchTime, 0, 10);
$time = substr($punchTime, 11, 8);
// Determine type if unknown
if ($punchType === 'unknown') {
$punchType = self::determinePunchType((int) $profile['id'], $date);
}
try {
if ($punchType === 'in') {
$result = AttendanceService::recordCheckIn(
(int) $profile['id'],
$date,
$time,
'biometric'
);
} elseif ($punchType === 'out') {
$result = AttendanceService::recordCheckOut(
(int) $profile['id'],
$date,
$time,
'biometric'
);
} else {
$result = ['success' => false, 'error' => 'Cannot determine punch type'];
}
if ($result['success']) {
// Get the attendance record id
$attendance = $db->selectOne(
"SELECT id FROM hr_attendance WHERE employee_profile_id = ? AND attendance_date = ? AND is_archived = 0",
[(int) $profile['id'], $date]
);
$db->update('hr_biometric_punches', [
'processed' => 1,
'attendance_id' => $attendance ? (int) $attendance['id'] : null,
'punch_type' => $punchType,
], '`id` = ?', [$punchId]);
} else {
$db->update('hr_biometric_punches', [
'processed' => 1,
'error_message' => $result['error'] ?? 'Attendance recording failed',
'punch_type' => $punchType,
], '`id` = ?', [$punchId]);
}
} catch (\Throwable $e) {
Logger::error('BiometricService::processPunch error', [
'punch_id' => $punchId,
'error' => $e->getMessage(),
]);
$db->update('hr_biometric_punches', [
'processed' => 1,
'error_message' => $e->getMessage(),
], '`id` = ?', [$punchId]);
}
} else {
// Employee not matched — leave as unprocessed for manual review
$db->update('hr_biometric_punches', [
'error_message' => 'Employee not found for fingerprint code: ' . $fingerprintCode,
], '`id` = ?', [$punchId]);
}
return ['success' => true, 'punch_id' => $punchId];
}
/**
* Process all pending (unprocessed) punches.
*
* @return array {processed: int, failed: int, errors: array}
*/
public static function processAllPending(): array
{
$db = App::getInstance()->db();
$pending = $db->select(
"SELECT p.*, d.serial_number as device_serial
FROM hr_biometric_punches p
JOIN hr_biometric_devices d ON d.id = p.device_id
WHERE p.processed = 0
ORDER BY p.punch_time ASC"
);
$processed = 0;
$failed = 0;
$errors = [];
foreach ($pending as $punch) {
$profile = self::resolveEmployee($punch['fingerprint_code']);
if (!$profile) {
$db->update('hr_biometric_punches', [
'processed' => 1,
'error_message' => 'Employee not found for fingerprint code: ' . $punch['fingerprint_code'],
], '`id` = ?', [(int) $punch['id']]);
$failed++;
$errors[] = "Punch #{$punch['id']}: Employee not found for code {$punch['fingerprint_code']}";
continue;
}
$date = substr($punch['punch_time'], 0, 10);
$time = substr($punch['punch_time'], 11, 8);
$punchType = $punch['punch_type'];
if ($punchType === 'unknown') {
$punchType = self::determinePunchType((int) $profile['id'], $date);
}
try {
if ($punchType === 'in') {
$result = AttendanceService::recordCheckIn(
(int) $profile['id'],
$date,
$time,
'biometric'
);
} elseif ($punchType === 'out') {
$result = AttendanceService::recordCheckOut(
(int) $profile['id'],
$date,
$time,
'biometric'
);
} else {
$result = ['success' => false, 'error' => 'Cannot determine punch type'];
}
if ($result['success']) {
$attendance = $db->selectOne(
"SELECT id FROM hr_attendance WHERE employee_profile_id = ? AND attendance_date = ? AND is_archived = 0",
[(int) $profile['id'], $date]
);
$db->update('hr_biometric_punches', [
'processed' => 1,
'attendance_id' => $attendance ? (int) $attendance['id'] : null,
'punch_type' => $punchType,
], '`id` = ?', [(int) $punch['id']]);
$processed++;
} else {
$db->update('hr_biometric_punches', [
'processed' => 1,
'error_message' => $result['error'] ?? 'Attendance recording failed',
'punch_type' => $punchType,
], '`id` = ?', [(int) $punch['id']]);
$failed++;
$errors[] = "Punch #{$punch['id']}: " . ($result['error'] ?? 'Unknown error');
}
} catch (\Throwable $e) {
Logger::error('BiometricService::processAllPending error', [
'punch_id' => $punch['id'],
'error' => $e->getMessage(),
]);
$db->update('hr_biometric_punches', [
'processed' => 1,
'error_message' => $e->getMessage(),
], '`id` = ?', [(int) $punch['id']]);
$failed++;
$errors[] = "Punch #{$punch['id']}: " . $e->getMessage();
}
}
return [
'processed' => $processed,
'failed' => $failed,
'errors' => $errors,
];
}
/**
* Resolve an employee profile from a fingerprint code.
*
* @return array|null Profile row or null if not found
*/
public static function resolveEmployee(string $fingerprintCode): ?array
{
$db = App::getInstance()->db();
$profile = $db->selectOne(
"SELECT id, employee_id, employee_number, first_name_ar, last_name_ar
FROM hr_employee_profiles
WHERE fingerprint_code = ? AND employment_status = 'active' AND is_archived = 0",
[$fingerprintCode]
);
return $profile ?: null;
}
/**
* Determine punch type based on current attendance state for the day.
*
* Logic:
* - No check_in today => 'in'
* - check_in exists, no check_out => 'out'
* - Both exist => 'unknown'
*/
public static function determinePunchType(int $profileId, string $date): string
{
$db = App::getInstance()->db();
$attendance = $db->selectOne(
"SELECT check_in_time, check_out_time
FROM hr_attendance
WHERE employee_profile_id = ? AND attendance_date = ? AND is_archived = 0",
[$profileId, $date]
);
if (!$attendance) {
return 'in';
}
if ($attendance['check_in_time'] !== null && $attendance['check_out_time'] === null) {
return 'out';
}
return 'unknown';
}
}
...@@ -27,22 +27,33 @@ final class LeaveService ...@@ -27,22 +27,33 @@ final class LeaveService
{ {
public static function calculateAnnualEntitlement(array $profile): string public static function calculateAnnualEntitlement(array $profile): string
{ {
$db = App::getInstance()->db();
$hireDate = $profile['hire_date'] ?? null; $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) { if (!$hireDate) {
return '21.0'; return $daysUnder10;
} }
$yearsOfService = (float) HrEmployeeProfile::getYearsOfService($hireDate); $yearsOfService = (float) HrEmployeeProfile::getYearsOfService($hireDate);
$age = self::calculateAge($profile['date_of_birth'] ?? ''); $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) { if ($yearsOfService >= 10 || $age >= 50) {
return '30.0'; return $daysOver10;
} }
return '21.0'; return $daysUnder10;
} }
public static function submitRequest(array $data): array public static function submitRequest(array $data): array
...@@ -81,18 +92,30 @@ final class LeaveService ...@@ -81,18 +92,30 @@ final class LeaveService
$totalDays = '0.5'; $totalDays = '0.5';
} }
// Validate against limits $isUnpaid = ($leaveType['category'] === 'unpaid' || ($leaveType['is_paid'] ?? 1) == 0);
if ($leaveType['max_days_per_request'] && bccomp($totalDays, $leaveType['max_days_per_request'], 1) > 0) {
return ['success' => false, 'error' => 'تجاوز الحد الأقصى لأيام الطلب الواحد (' . $leaveType['max_days_per_request'] . ' يوم)']; // 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) { if ($leaveType['min_days_per_request'] && bccomp($totalDays, $leaveType['min_days_per_request'], 1) < 0) {
return ['success' => false, 'error' => 'الحد الأدنى للطلب هو ' . $leaveType['min_days_per_request'] . ' يوم']; 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)); $year = (int) date('Y', strtotime($startDate));
$balance = HrLeaveBalance::getBalance($profileId, $leaveTypeId, $year); $balance = HrLeaveBalance::getBalance($profileId, $leaveTypeId, $year);
if ($balance) { if ($balance && !$isUnpaid) {
$remaining = bcsub( $remaining = bcsub(
bcadd(bcadd($balance['entitled_days'], $balance['carried_over_days'], 1), $balance['adjustment_days'], 1), bcadd(bcadd($balance['entitled_days'], $balance['carried_over_days'], 1), $balance['adjustment_days'], 1),
bcadd($balance['used_days'], $balance['pending_days'], 1), bcadd($balance['used_days'], $balance['pending_days'], 1),
...@@ -287,6 +310,8 @@ final class LeaveService ...@@ -287,6 +310,8 @@ final class LeaveService
$entitled = $lt['default_days_per_year']; $entitled = $lt['default_days_per_year'];
if ($lt['code'] === 'annual') { if ($lt['code'] === 'annual') {
$entitled = $annualEntitlement; $entitled = $annualEntitlement;
} elseif ($lt['code'] === 'maternity') {
$entitled = self::getMaternityDays();
} }
// Calculate carry-over from previous year // Calculate carry-over from previous year
...@@ -342,6 +367,64 @@ final class LeaveService ...@@ -342,6 +367,64 @@ final class LeaveService
return number_format($days, 1, '.', ''); 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 private static function calculateAge(string $dob): int
{ {
if (empty($dob)) return 0; if (empty($dob)) return 0;
......
...@@ -299,11 +299,75 @@ final class PayrollCalculationService ...@@ -299,11 +299,75 @@ final class PayrollCalculationService
'sort_order' => $sortOrder, '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 ── // ── Step 9: Net salary ──
$otherDeductions = bcadd(
bcadd(bcadd($martyrsFundDeduction, $regularStampDeduction, 2), $additionalStampDeduction, 2),
$externalLoanDeduction,
2
);
$totalDeductions = bcadd( $totalDeductions = bcadd(
bcadd(
bcadd(bcadd(bcadd($insuranceEmployee, $taxAmount, 2), $loanDeduction, 2), $penaltyDeduction, 2), bcadd(bcadd(bcadd($insuranceEmployee, $taxAmount, 2), $loanDeduction, 2), $penaltyDeduction, 2),
$absenceDeduction, $absenceDeduction,
2 2
),
$otherDeductions,
2
); );
$netSalary = bcsub($grossEarnings, $totalDeductions, 2); $netSalary = bcsub($grossEarnings, $totalDeductions, 2);
if (bccomp($netSalary, '0', 2) < 0) { if (bccomp($netSalary, '0', 2) < 0) {
...@@ -337,7 +401,7 @@ final class PayrollCalculationService ...@@ -337,7 +401,7 @@ final class PayrollCalculationService
'loan_deduction' => $loanDeduction, 'loan_deduction' => $loanDeduction,
'penalty_deduction' => $penaltyDeduction, 'penalty_deduction' => $penaltyDeduction,
'absence_deduction' => $absenceDeduction, 'absence_deduction' => $absenceDeduction,
'other_deductions' => '0.00', 'other_deductions' => $otherDeductions,
'net_salary' => $netSalary, 'net_salary' => $netSalary,
'working_days' => $workingDays, 'working_days' => $workingDays,
'present_days' => $presentDays, '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 @@ ...@@ -12,13 +12,21 @@
<div class="card" style="margin-bottom:20px;"> <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;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;"> <div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;">
<?php if (!$isEdit): ?>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الموظف <span style="color:#DC2626;">*</span></label> <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')) ?>"> <?php if ($isEdit): ?>
<input type="text" class="form-control" value="<?= $profile ? e($profile->first_name_ar . ' ' . $profile->last_name_ar) : '' ?>" readonly> <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> </div>
<?php endif; ?> <?php endif; ?>
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">نوع العقد <span style="color:#DC2626;">*</span></label> <label style="display:block;margin-bottom:4px;font-size:13px;">نوع العقد <span style="color:#DC2626;">*</span></label>
<select name="contract_type" class="form-control" required> <select name="contract_type" class="form-control" required>
...@@ -71,4 +79,60 @@ ...@@ -71,4 +79,60 @@
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'تحديث' : 'حفظ' ?></button> <button type="submit" class="btn btn-primary"><?= $isEdit ? 'تحديث' : 'حفظ' ?></button>
</div> </div>
</form> </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->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 @@ ...@@ -54,6 +54,10 @@
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </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> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الحالة</label> <label style="display:block;margin-bottom:4px;font-size:13px;">الحالة</label>
<select name="is_active" class="form-control"> <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 @@ ...@@ -35,6 +35,84 @@
</div> </div>
</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)): ?> <?php if (!empty($children)): ?>
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;"> <div style="padding:16px;border-bottom:1px solid #E5E7EB;">
......
...@@ -16,6 +16,7 @@ ...@@ -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><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;">وصف الواقعة <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_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> </div>
<div style="display:flex;gap:10px;justify-content:flex-end;"> <div style="display:flex;gap:10px;justify-content:flex-end;">
......
...@@ -23,6 +23,12 @@ ...@@ -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 style="margin-top:4px;padding:12px;background:#F9FAFB;border-radius:6px;line-height:1.6;"><?= nl2br(e($action->investigation_notes)) ?></div>
</div> </div>
<?php endif; ?> <?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> </div>
<?php if ($action->penalty_type): ?> <?php if ($action->penalty_type): ?>
......
...@@ -15,16 +15,24 @@ ...@@ -15,16 +15,24 @@
<option value="1" <?= $filters['expiring_soon'] === '1' ? 'selected' : '' ?>>منتهية/قاربت على الانتهاء</option> <option value="1" <?= $filters['expiring_soon'] === '1' ? 'selected' : '' ?>>منتهية/قاربت على الانتهاء</option>
</select> </select>
</div> </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> <button type="submit" class="btn btn-secondary">بحث</button>
</form> </form>
</div> </div>
<div class="card"> <div class="card">
<div class="table-responsive"> <div class="table-responsive">
<table class="data-table"> <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> <tbody>
<?php if (empty($documents)): ?> <?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 else: ?>
<?php foreach ($documents as $d): ?> <?php foreach ($documents as $d): ?>
<?php $isExpired = !empty($d['expiry_date']) && $d['expiry_date'] < date('Y-m-d'); ?> <?php $isExpired = !empty($d['expiry_date']) && $d['expiry_date'] < date('Y-m-d'); ?>
...@@ -34,6 +42,13 @@ ...@@ -34,6 +42,13 @@
<td><?= e($documentTypes[$d['document_type']] ?? $d['document_type']) ?></td> <td><?= e($documentTypes[$d['document_type']] ?? $d['document_type']) ?></td>
<td><?= e($d['title_ar'] ?? '-') ?></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 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><?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> <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> </tr>
......
...@@ -81,6 +81,10 @@ ...@@ -81,6 +81,10 @@
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الجنسية</label>
<input type="text" name="nationality" value="<?= e(old('nationality') ?? ($isEdit ? $profile->nationality : 'egyptian')) ?>" class="form-control">
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الهاتف</label> <label style="display:block;margin-bottom:4px;font-size:13px;">الهاتف</label>
<input type="text" name="phone" value="<?= e(old('phone') ?? ($isEdit ? $profile->phone : '')) ?>" class="form-control"> <input type="text" name="phone" value="<?= e(old('phone') ?? ($isEdit ? $profile->phone : '')) ?>" class="form-control">
...@@ -89,6 +93,15 @@ ...@@ -89,6 +93,15 @@
<label style="display:block;margin-bottom:4px;font-size:13px;">البريد الإلكتروني</label> <label style="display:block;margin-bottom:4px;font-size:13px;">البريد الإلكتروني</label>
<input type="email" name="email" value="<?= e(old('email') ?? ($isEdit ? $profile->email : '')) ?>" class="form-control"> <input type="email" name="email" value="<?= e(old('email') ?? ($isEdit ? $profile->email : '')) ?>" class="form-control">
</div> </div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">موقف التجنيد</label>
<select name="military_status" class="form-control">
<option value="">-- غير محدد --</option>
<?php foreach ($militaryStatuses as $k => $v): ?>
<option value="<?= e($k) ?>" <?= (old('military_status') ?? ($isEdit ? $profile->military_status : '')) === $k ? 'selected' : '' ?>><?= e($v) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="grid-column:1/-1;"> <div style="grid-column:1/-1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">العنوان</label> <label style="display:block;margin-bottom:4px;font-size:13px;">العنوان</label>
<input type="text" name="address" value="<?= e(old('address') ?? ($isEdit ? $profile->address : '')) ?>" class="form-control"> <input type="text" name="address" value="<?= e(old('address') ?? ($isEdit ? $profile->address : '')) ?>" class="form-control">
...@@ -96,9 +109,39 @@ ...@@ -96,9 +109,39 @@
</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;">المؤهل الدراسي</h3></div>
<div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;">
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">المؤهل</label>
<input type="text" name="education_qualification" value="<?= e(old('education_qualification') ?? ($isEdit ? $profile->education_qualification : '')) ?>" class="form-control" placeholder="مثال: بكالوريوس - ماجستير - دبلوم">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">التخصص</label>
<input type="text" name="education_specialization" value="<?= e(old('education_specialization') ?? ($isEdit ? $profile->education_specialization : '')) ?>" class="form-control">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">سنة التخرج</label>
<input type="number" name="education_graduation_year" value="<?= e(old('education_graduation_year') ?? ($isEdit ? $profile->education_graduation_year : '')) ?>" class="form-control" min="1970" max="2030" placeholder="مثال: 2015">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">التقدير</label>
<input type="text" name="education_grade" value="<?= e(old('education_grade') ?? ($isEdit ? $profile->education_grade : '')) ?>" class="form-control" placeholder="مثال: جيد جداً - امتياز">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الجامعة / المعهد</label>
<input type="text" name="education_university" value="<?= e(old('education_university') ?? ($isEdit ? $profile->education_university : '')) ?>" class="form-control">
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;"> <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;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(220px,1fr));gap:16px;"> <div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;">
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">كود البصمة</label>
<input type="text" name="fingerprint_code" value="<?= e(old('fingerprint_code') ?? ($isEdit ? $profile->fingerprint_code : '')) ?>" class="form-control" placeholder="رقم الموظف في جهاز البصمة">
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">القسم</label> <label style="display:block;margin-bottom:4px;font-size:13px;">القسم</label>
<select name="department_id" class="form-control"> <select name="department_id" class="form-control">
...@@ -117,6 +160,15 @@ ...@@ -117,6 +160,15 @@
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">المدير المباشر</label>
<select name="direct_manager_id" class="form-control">
<option value="">-- بدون --</option>
<?php foreach ($managers as $m): ?>
<option value="<?= (int) $m['id'] ?>" <?= (old('direct_manager_id') ?? ($isEdit ? $profile->direct_manager_id : '')) == $m['id'] ? 'selected' : '' ?>><?= e($m['first_name_ar'] . ' ' . $m['last_name_ar'] . ' (' . $m['employee_number'] . ')') ?></option>
<?php endforeach; ?>
</select>
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">تاريخ التعيين <span style="color:#DC2626;">*</span></label> <label style="display:block;margin-bottom:4px;font-size:13px;">تاريخ التعيين <span style="color:#DC2626;">*</span></label>
<input type="date" name="hire_date" value="<?= e(old('hire_date') ?? ($isEdit ? $profile->hire_date : '')) ?>" class="form-control" required> <input type="date" name="hire_date" value="<?= e(old('hire_date') ?? ($isEdit ? $profile->hire_date : '')) ?>" class="form-control" required>
...@@ -129,6 +181,14 @@ ...@@ -129,6 +181,14 @@
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">نوع العمالة</label>
<select name="workforce_type" class="form-control">
<?php foreach ($workforceTypes as $k => $v): ?>
<option value="<?= e($k) ?>" <?= (old('workforce_type') ?? ($isEdit ? $profile->workforce_type : 'insured')) === $k ? 'selected' : '' ?>><?= e($v) ?></option>
<?php endforeach; ?>
</select>
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">نهاية فترة الاختبار</label> <label style="display:block;margin-bottom:4px;font-size:13px;">نهاية فترة الاختبار</label>
<input type="date" name="probation_end_date" value="<?= e(old('probation_end_date') ?? ($isEdit ? $profile->probation_end_date : '')) ?>" class="form-control"> <input type="date" name="probation_end_date" value="<?= e(old('probation_end_date') ?? ($isEdit ? $profile->probation_end_date : '')) ?>" class="form-control">
...@@ -145,7 +205,7 @@ ...@@ -145,7 +205,7 @@
</div> </div>
<div class="card" style="margin-bottom:20px;"> <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;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(220px,1fr));gap:16px;"> <div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;">
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الراتب الأساسي <span style="color:#DC2626;">*</span></label> <label style="display:block;margin-bottom:4px;font-size:13px;">الراتب الأساسي <span style="color:#DC2626;">*</span></label>
...@@ -161,13 +221,21 @@ ...@@ -161,13 +221,21 @@
</select> </select>
</div> </div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الراتب التأميني</label> <label style="display:block;margin-bottom:4px;font-size:13px;">الأجر التأميني</label>
<input type="text" name="insurable_salary" value="<?= e(old('insurable_salary') ?? ($isEdit ? $profile->insurable_salary : '')) ?>" class="form-control" placeholder="إذا يختلف عن الأساسي"> <input type="text" name="insurable_salary" value="<?= e(old('insurable_salary') ?? ($isEdit ? $profile->insurable_salary : '')) ?>" class="form-control" placeholder="إذا يختلف عن الأساسي">
</div> </div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">رقم التأمينات</label> <label style="display:block;margin-bottom:4px;font-size:13px;">الرقم التأميني</label>
<input type="text" name="insurance_number" value="<?= e(old('insurance_number') ?? ($isEdit ? $profile->insurance_number : '')) ?>" class="form-control"> <input type="text" name="insurance_number" value="<?= e(old('insurance_number') ?? ($isEdit ? $profile->insurance_number : '')) ?>" class="form-control">
</div> </div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">تاريخ التأمين</label>
<input type="date" name="insurance_start_date" value="<?= e(old('insurance_start_date') ?? ($isEdit ? $profile->insurance_start_date : '')) ?>" class="form-control">
</div>
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">رقم البطاقة الضريبية</label>
<input type="text" name="tax_card_number" value="<?= e(old('tax_card_number') ?? ($isEdit ? $profile->tax_card_number : '')) ?>" class="form-control">
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">اسم البنك</label> <label style="display:block;margin-bottom:4px;font-size:13px;">اسم البنك</label>
<input type="text" name="bank_name" value="<?= e(old('bank_name') ?? ($isEdit ? $profile->bank_name : '')) ?>" class="form-control"> <input type="text" name="bank_name" value="<?= e(old('bank_name') ?? ($isEdit ? $profile->bank_name : '')) ?>" class="form-control">
...@@ -186,15 +254,6 @@ ...@@ -186,15 +254,6 @@
<div class="card" style="margin-bottom:20px;"> <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;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(220px,1fr));gap:16px;"> <div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;">
<div>
<label style="display:block;margin-bottom:4px;font-size:13px;">الموقف من التجنيد</label>
<select name="military_status" class="form-control">
<option value="">-- غير محدد --</option>
<?php foreach ($militaryStatuses as $k => $v): ?>
<option value="<?= e($k) ?>" <?= (old('military_status') ?? ($isEdit ? $profile->military_status : '')) === $k ? 'selected' : '' ?>><?= e($v) ?></option>
<?php endforeach; ?>
</select>
</div>
<div> <div>
<label style="display:block;margin-bottom:4px;font-size:13px;">اسم جهة الاتصال الطارئ</label> <label style="display:block;margin-bottom:4px;font-size:13px;">اسم جهة الاتصال الطارئ</label>
<input type="text" name="emergency_contact_name" value="<?= e(old('emergency_contact_name') ?? ($isEdit ? $profile->emergency_contact_name : '')) ?>" class="form-control"> <input type="text" name="emergency_contact_name" value="<?= e(old('emergency_contact_name') ?? ($isEdit ? $profile->emergency_contact_name : '')) ?>" class="form-control">
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
</div> </div>
<div style="padding:16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;"> <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: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><span style="color:#6B7280;font-size:13px;">هيكل الرواتب</span><div><?= $structure ? e($structure->name_ar) : '<span style="color:#DC2626;">غير محدد</span>' ?></div></div>
</div> </div>
</div> </div>
......
...@@ -33,6 +33,8 @@ $sc = $statusColors[$profile->employment_status] ?? '#E5E7EB;color:#374151'; ...@@ -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($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($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($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->phone ?? '-') ?></div></div>
<div><span style="color:#6B7280;font-size:13px;">البريد</span><div><?= e($profile->email ?? '-') ?></div></div> <div><span style="color:#6B7280;font-size:13px;">البريد</span><div><?= e($profile->email ?? '-') ?></div></div>
</div> </div>
...@@ -50,11 +52,32 @@ $sc = $statusColors[$profile->employment_status] ?? '#E5E7EB;color:#374151'; ...@@ -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><?= 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 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($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><?= $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')): ?> <?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: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; ?> <?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>
</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 @@ ...@@ -40,6 +40,10 @@
<label style="display:block;margin-bottom:4px;font-size:13px;">الوصف</label> <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> <textarea name="description_ar" class="form-control" rows="3"><?= e(old('description_ar') ?? ($isEdit ? $jobTitle->description_ar : '')) ?></textarea>
</div> </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> </div>
<div style="display:flex;gap:10px;justify-content:flex-end;"> <div style="display:flex;gap:10px;justify-content:flex-end;">
......
...@@ -12,16 +12,25 @@ ...@@ -12,16 +12,25 @@
</select> </select>
</div> </div>
<div><label style="display:block;margin-bottom:4px;font-size:13px;">نوع السلفة <span style="color:#DC2626;">*</span></label> <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="salary_advance" <?= old('loan_type') === 'salary_advance' ? 'selected' : '' ?>>سلفة راتب</option>
<option value="personal_loan" <?= old('loan_type') === 'personal_loan' ? '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="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> </select>
</div> </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="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;">عدد الأقساط <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;">تاريخ الطلب</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><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="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 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> </div>
...@@ -31,4 +40,16 @@ ...@@ -31,4 +40,16 @@
<button type="submit" class="btn btn-primary">تقديم الطلب</button> <button type="submit" class="btn btn-primary">تقديم الطلب</button>
</div> </div>
</form> </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(); ?> <?php $__template->endSection(); ?>
...@@ -13,10 +13,10 @@ ...@@ -13,10 +13,10 @@
<div style="margin-bottom:15px;"> <div style="margin-bottom:15px;">
<label class="form-label">الموظف <span style="color:#DC2626;">*</span></label> <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> <option value="">-- اختر الموظف --</option>
<?php foreach ($employees as $emp): ?> <?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; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
......
...@@ -98,6 +98,10 @@ PermissionRegistry::register('hr', [ ...@@ -98,6 +98,10 @@ PermissionRegistry::register('hr', [
'hr.permissions.view' => ['ar' => 'عرض طلبات الأذونات', 'en' => 'View Permission Requests'], 'hr.permissions.view' => ['ar' => 'عرض طلبات الأذونات', 'en' => 'View Permission Requests'],
'hr.permissions.create' => ['ar' => 'تقديم طلب إذن', 'en' => 'Submit Permission Request'], 'hr.permissions.create' => ['ar' => 'تقديم طلب إذن', 'en' => 'Submit Permission Request'],
'hr.permissions.approve' => ['ar' => 'اعتماد طلبات الأذونات', 'en' => 'Approve Permission Requests'], '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', [ ...@@ -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' => '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' => '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' => '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],
], ],
]); ]);
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
// =====================================================================
// 1A. Employee Profile — New Columns
// =====================================================================
$cols = [
'workforce_type' => "ALTER TABLE hr_employee_profiles ADD COLUMN workforce_type VARCHAR(30) NULL DEFAULT 'insured' AFTER employment_type",
'fingerprint_code' => "ALTER TABLE hr_employee_profiles ADD COLUMN fingerprint_code VARCHAR(50) NULL AFTER employee_number",
'education_qualification' => "ALTER TABLE hr_employee_profiles ADD COLUMN education_qualification VARCHAR(255) NULL AFTER nationality",
'education_specialization' => "ALTER TABLE hr_employee_profiles ADD COLUMN education_specialization VARCHAR(255) NULL AFTER education_qualification",
'education_graduation_year'=> "ALTER TABLE hr_employee_profiles ADD COLUMN education_graduation_year YEAR NULL AFTER education_specialization",
'education_grade' => "ALTER TABLE hr_employee_profiles ADD COLUMN education_grade VARCHAR(100) NULL AFTER education_graduation_year",
'education_university' => "ALTER TABLE hr_employee_profiles ADD COLUMN education_university VARCHAR(255) NULL AFTER education_grade",
];
foreach ($cols as $col => $sql) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_employee_profiles' AND column_name = ?",
[$col]
);
if (!$exists) {
$db->raw($sql);
}
}
// =====================================================================
// 1B. Department Staffing Capacity
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_departments' AND column_name = 'staffing_capacity'"
);
if (!$exists) {
$db->raw("ALTER TABLE hr_departments ADD COLUMN staffing_capacity INT UNSIGNED NULL AFTER sort_order");
}
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'hr_department_positions'"
);
if (!$exists) {
$db->raw("
CREATE TABLE hr_department_positions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
department_id BIGINT UNSIGNED NOT NULL,
job_title_id BIGINT UNSIGNED NOT NULL,
authorized_count INT UNSIGNED NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_dept_job (department_id, job_title_id),
KEY idx_department (department_id),
KEY idx_job_title (job_title_id),
CONSTRAINT fk_dept_pos_dept FOREIGN KEY (department_id) REFERENCES hr_departments(id) ON DELETE CASCADE,
CONSTRAINT fk_dept_pos_job FOREIGN KEY (job_title_id) REFERENCES hr_job_titles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
// =====================================================================
// 1C. Job Title — Description Fields
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_job_titles' AND column_name = 'job_description_ar'"
);
if (!$exists) {
$db->raw("ALTER TABLE hr_job_titles ADD COLUMN job_description_ar TEXT NULL");
$db->raw("ALTER TABLE hr_job_titles ADD COLUMN job_description_en TEXT NULL");
}
// =====================================================================
// 1D. Loans — External Entity Columns
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_employee_loans' AND column_name = 'external_entity_name'"
);
if (!$exists) {
$db->raw("ALTER TABLE hr_employee_loans ADD COLUMN external_entity_name VARCHAR(255) NULL AFTER loan_type");
$db->raw("ALTER TABLE hr_employee_loans ADD COLUMN external_reference VARCHAR(100) NULL AFTER external_entity_name");
$db->raw("ALTER TABLE hr_employee_loans ADD COLUMN monthly_salary_impact DECIMAL(12,2) NULL AFTER external_reference");
}
// Drop the old CHECK constraint and add new one with expanded types
try {
$db->raw("ALTER TABLE hr_employee_loans DROP CONSTRAINT chk_hr_loans_type");
} catch (\Exception $e) {
// Constraint may not exist or already dropped
}
try {
$db->raw("ALTER TABLE hr_employee_loans ADD CONSTRAINT chk_hr_loans_type CHECK (loan_type IN ('salary_advance', 'personal_loan', 'emergency_loan', 'financing_company', 'bank_loan'))");
} catch (\Exception $e) {
// May already exist with new values
}
// =====================================================================
// 1E. Documents — Validity Status
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_employee_documents' AND column_name = 'validity_status'"
);
if (!$exists) {
$db->raw("ALTER TABLE hr_employee_documents ADD COLUMN validity_status VARCHAR(20) NOT NULL DEFAULT 'valid' AFTER is_verified");
}
// =====================================================================
// 1F. Disciplinary — Investigation Result
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_disciplinary_actions' AND column_name = 'investigation_result'"
);
if (!$exists) {
$db->raw("ALTER TABLE hr_disciplinary_actions ADD COLUMN investigation_result TEXT NULL AFTER investigation_notes");
}
// =====================================================================
// 1G. Overtime — Add employee_profile_id Column
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'hr_overtime_requests' AND column_name = 'employee_profile_id'"
);
if (!$exists) {
$db->raw("ALTER TABLE hr_overtime_requests ADD COLUMN employee_profile_id BIGINT UNSIGNED NULL AFTER employee_id");
$db->raw("CREATE INDEX idx_ot_profile ON hr_overtime_requests (employee_profile_id)");
// Backfill existing data
$db->raw("
UPDATE hr_overtime_requests r
JOIN hr_employee_profiles p ON p.employee_id = r.employee_id
SET r.employee_profile_id = p.id
WHERE r.employee_profile_id IS NULL
");
}
// =====================================================================
// 1H. Biometric Integration Tables
// =====================================================================
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'hr_biometric_devices'"
);
if (!$exists) {
$db->raw("
CREATE TABLE hr_biometric_devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
device_name VARCHAR(100) NOT NULL,
device_type VARCHAR(20) NOT NULL DEFAULT 'fingerprint',
ip_address VARCHAR(45) NULL,
port INT UNSIGNED NULL,
serial_number VARCHAR(100) NULL,
location_description VARCHAR(255) NULL,
branch_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_sync_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'hr_biometric_punches'"
);
if (!$exists) {
$db->raw("
CREATE TABLE hr_biometric_punches (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
device_id BIGINT UNSIGNED NOT NULL,
fingerprint_code VARCHAR(50) NOT NULL,
punch_time DATETIME NOT NULL,
punch_type VARCHAR(10) NOT NULL DEFAULT 'unknown',
processed TINYINT(1) NOT NULL DEFAULT 0,
attendance_id BIGINT UNSIGNED NULL,
error_message VARCHAR(500) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_fingerprint (fingerprint_code),
KEY idx_punch_time (punch_time),
KEY idx_processed (processed),
KEY idx_device (device_id),
CONSTRAINT fk_punch_device FOREIGN KEY (device_id) REFERENCES hr_biometric_devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
// =====================================================================
// 1I. Military Status Data Migration
// =====================================================================
$db->raw("UPDATE hr_employee_profiles SET military_status = 'served' WHERE military_status = 'completed'");
$db->raw("UPDATE hr_employee_profiles SET military_status = 'final_exemption' WHERE military_status = 'exempted'");
$db->raw("UPDATE hr_employee_profiles SET military_status = 'temporary_exemption' WHERE military_status = 'postponed'");
$db->raw("UPDATE hr_employee_profiles SET military_status = 'female' WHERE military_status = 'not_applicable'");
// =====================================================================
// 1J. System Config Seeds for HR Deductions & Leave
// =====================================================================
$configs = [
['hr.deduction.martyrs_fund_amount', '0', 'decimal', 'hr', 'خصم صندوق الشهداء - مبلغ شهري ثابت'],
['hr.deduction.regular_stamp_duty', '0', 'decimal', 'hr', 'خصم الدمغة العادية - مبلغ شهري'],
['hr.deduction.additional_stamp_duty', '0', 'decimal', 'hr', 'خصم الدمغة الإضافية - مبلغ شهري'],
['hr.leave.annual_days_under_10y', '21', 'integer', 'hr', 'أيام الإجازة السنوية - أقل من 10 سنوات خدمة'],
['hr.leave.annual_days_over_10y', '30', 'integer', 'hr', 'أيام الإجازة السنوية - 10 سنوات فأكثر أو سن 50+'],
['hr.leave.maternity_days', '90', 'integer', 'hr', 'أيام إجازة الوضع'],
['hr.leave.maternity_max_career', '3', 'integer', 'hr', 'الحد الأقصى لإجازات الوضع خلال المسيرة المهنية'],
['hr.leave.breastfeeding_daily_hours', '1', 'decimal', 'hr', 'ساعة الرضاعة اليومية بدون خصم'],
['hr.leave.unpaid_max_days', '0', 'integer', 'hr', 'الحد الأقصى للإجازة بدون مرتب (0 = غير محدد)'],
];
foreach ($configs as [$key, $value, $type, $group, $desc]) {
$exists = $db->selectOne("SELECT 1 FROM system_config WHERE config_key = ?", [$key]);
if (!$exists) {
$db->insert('system_config', [
'config_key' => $key,
'config_value' => $value,
'config_type' => $type,
'group_name' => $group,
'description_ar' => $desc,
'is_editable' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
// =====================================================================
// 1K. Permission Seed — Assign All HR Permissions to Super Admin
// =====================================================================
$superAdmin = $db->selectOne("SELECT id FROM roles WHERE role_code = 'super_admin'");
if ($superAdmin) {
$roleId = (int) $superAdmin['id'];
$hrPermissions = [
'hr.overtime.view', 'hr.overtime.create', 'hr.overtime.approve', 'hr.overtime.manage',
'hr.shifts.view', 'hr.shifts.manage',
'hr.permissions.view', 'hr.permissions.create', 'hr.permissions.approve',
'hr.performance.view', 'hr.performance.manage',
'hr.disciplinary.view', 'hr.disciplinary.manage',
'hr.department.view', 'hr.department.manage',
'hr.job_title.view', 'hr.job_title.manage',
'hr.employee.view', 'hr.employee.manage', 'hr.employee.view_salary', 'hr.employee.manage_salary',
'hr.contract.view', 'hr.contract.manage',
'hr.attendance.view', 'hr.attendance.manage', 'hr.attendance.approve',
'hr.leave.view', 'hr.leave.request', 'hr.leave.approve', 'hr.leave.manage',
'hr.payroll.view', 'hr.payroll.process', 'hr.payroll.approve', 'hr.payslip.view_own',
'hr.insurance.view', 'hr.insurance.manage',
'hr.tax.view',
'hr.loan.view', 'hr.loan.manage', 'hr.loan.approve',
'hr.eos.view', 'hr.eos.manage', 'hr.eos.approve',
'hr.document.view', 'hr.document.manage',
'hr.report.view',
'hr.holiday.manage',
'hr.schedule.manage',
'hr.biometric.view', 'hr.biometric.manage',
];
foreach ($hrPermissions as $key) {
$exists = $db->selectOne(
"SELECT 1 FROM role_permissions WHERE role_id = ? AND permission_key = ?",
[$roleId, $key]
);
if (!$exists) {
$db->insert('role_permissions', [
'role_id' => $roleId,
'permission_key' => $key,
'granted_at' => $now,
]);
}
}
}
};
# Cross-Module Dependency Graph # Cross-Module Dependency Graph
> **Generated:** 2026-06-11 > **Generated:** 2026-07-28
> **Source:** 75 architecture maps in `/docs/architecture-maps/` > **Source:** 75 architecture maps in `/docs/architecture-maps/`
> **Purpose:** Single source of truth for cross-module relationships > **Purpose:** Single source of truth for cross-module relationships
...@@ -137,7 +137,7 @@ ...@@ -137,7 +137,7 @@
| `hr.loan.approved` | HR/LoanService | HR/bootstrap (SMS) | | `hr.loan.approved` | HR/LoanService | HR/bootstrap (SMS) |
| `hr.contract.renewed` | HR/ContractService | (no external listeners) | | `hr.contract.renewed` | HR/ContractService | (no external listeners) |
| `hr.contract.terminated` | 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) | | `attendance.violations_detected` | HR/AttendanceViolationService | (no external listeners) |
### 2.6 Fines & Violations Events ### 2.6 Fines & Violations Events
......
# HR Module — Architecture Map # HR Module — Architecture Map
> **Last updated:** 2026-06-10 > **Last updated:** 2026-07-28
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -50,7 +50,8 @@ app/Modules/HR/ ...@@ -50,7 +50,8 @@ app/Modules/HR/
│ ├── EndOfServiceController.php # CRUD, calculate, approve, mark paid │ ├── EndOfServiceController.php # CRUD, calculate, approve, mark paid
│ ├── HolidayController.php # CRUD for official holidays │ ├── HolidayController.php # CRUD for official holidays
│ ├── HrReportController.php # 8 report endpoints │ ├── HrReportController.php # 8 report endpoints
│ ├── InsuranceController.php # View records, Form1, Form6 (Egyptian forms) │ ├── BiometricController.php # Device management, punch reception, processing
│ ├── InsuranceController.php # View records, Form1, Form2, Form6 (Egyptian forms)
│ ├── JobTitleController.php # CRUD for job titles │ ├── JobTitleController.php # CRUD for job titles
│ ├── LeaveController.php # Request, approve/reject, balances, calendar │ ├── LeaveController.php # Request, approve/reject, balances, calendar
│ ├── LoanController.php # CRUD, approve/reject, disburse │ ├── LoanController.php # CRUD, approve/reject, disburse
...@@ -66,6 +67,7 @@ app/Modules/HR/ ...@@ -66,6 +67,7 @@ app/Modules/HR/
│ ├── HrAttendance.php │ ├── HrAttendance.php
│ ├── HrContract.php │ ├── HrContract.php
│ ├── HrDepartment.php │ ├── HrDepartment.php
│ ├── HrDepartmentPosition.php # Department staffing positions (authorized headcount per job title)
│ ├── HrDisciplinaryAction.php │ ├── HrDisciplinaryAction.php
│ ├── HrEmployeeDocument.php │ ├── HrEmployeeDocument.php
│ ├── HrEmployeeLoan.php │ ├── HrEmployeeLoan.php
...@@ -89,7 +91,8 @@ app/Modules/HR/ ...@@ -89,7 +91,8 @@ app/Modules/HR/
│ ├── HrTaxRecord.php │ ├── HrTaxRecord.php
│ └── HrWorkSchedule.php │ └── HrWorkSchedule.php
├── Services/ ├── Services/
│ ├── AttendanceService.php # Check-in/out, hours calc, Ramadan detection │ ├── AttendanceService.php # Check-in/out, hours calc, Ramadan detection, breastfeeding tolerance
│ ├── BiometricService.php # Device punch processing, employee matching
│ ├── AttendanceViolationService.php # Auto-detect late/early/absence violations │ ├── AttendanceViolationService.php # Auto-detect late/early/absence violations
│ ├── ContractService.php # Renew, terminate contracts │ ├── ContractService.php # Renew, terminate contracts
│ ├── DisciplinaryService.php # Penalty calculation for payroll deduction │ ├── DisciplinaryService.php # Penalty calculation for payroll deduction
...@@ -97,7 +100,7 @@ app/Modules/HR/ ...@@ -97,7 +100,7 @@ app/Modules/HR/
│ ├── HrNumberGenerator.php # Employee number sequencing │ ├── HrNumberGenerator.php # Employee number sequencing
│ ├── IncomeTaxService.php # Progressive tax brackets (Egyptian law) │ ├── IncomeTaxService.php # Progressive tax brackets (Egyptian law)
│ ├── InsuranceCalculationService.php # Insurance form calculations │ ├── InsuranceCalculationService.php # Insurance form calculations
│ ├── LeaveService.php # Leave request lifecycle │ ├── LeaveService.php # Leave request lifecycle, configurable entitlement, breastfeeding eligibility
│ ├── LoanService.php # Loan lifecycle + installment processing │ ├── LoanService.php # Loan lifecycle + installment processing
│ ├── OvertimeService.php # Overtime amount calculation │ ├── OvertimeService.php # Overtime amount calculation
│ ├── PayrollCalculationService.php # Full payroll pipeline (CRITICAL) │ ├── PayrollCalculationService.php # Full payroll pipeline (CRITICAL)
...@@ -106,14 +109,15 @@ app/Modules/HR/ ...@@ -106,14 +109,15 @@ app/Modules/HR/
│ └── TaxBracketService.php # Tax bracket lookup │ └── TaxBracketService.php # Tax bracket lookup
└── Views/ └── Views/
├── attendance/ (4 views) ├── attendance/ (4 views)
├── contracts/ (3 views) ├── contracts/ (4 views: index, form, show, work_receipt)
├── departments/ (3 views) ├── departments/ (4 views: index, form, show, positions)
├── disciplinary/ (3 views) ├── disciplinary/ (3 views)
├── documents/ (2 views) ├── documents/ (2 views)
├── employees/ (4 views: index, form, show, salary) ├── employees/ (4 views: index, form, show, salary)
├── end_of_service/ (3 views) ├── end_of_service/ (3 views)
├── holidays/ (2 views) ├── holidays/ (2 views)
├── insurance/ (5 views: index, period, employee, form1, form6) ├── biometric/ (3 views: devices, punches, device_form)
├── insurance/ (6 views: index, period, employee, form1, form2, form6)
├── job_titles/ (2 views) ├── job_titles/ (2 views)
├── leaves/ (6 views: index, request_form, show, balances, employee_balance, calendar) ├── leaves/ (6 views: index, request_form, show, balances, employee_balance, calendar)
├── loans/ (3 views) ├── loans/ (3 views)
...@@ -138,6 +142,7 @@ app/Modules/HR/ ...@@ -138,6 +142,7 @@ app/Modules/HR/
| id | bigint unsigned | NO | PRI | auto_increment | | id | bigint unsigned | NO | PRI | auto_increment |
| employee_id | bigint unsigned | NO | UNI | FK to employees (system user) | | employee_id | bigint unsigned | NO | UNI | FK to employees (system user) |
| employee_number | varchar(20) | NO | UNI | HR employee code | | employee_number | varchar(20) | NO | UNI | HR employee code |
| fingerprint_code | varchar(50) | YES | | Biometric device matching |
| first_name_ar | varchar(100) | NO | | | | first_name_ar | varchar(100) | NO | | |
| last_name_ar | varchar(100) | NO | | | | last_name_ar | varchar(100) | NO | | |
| first_name_en | varchar(100) | YES | | | | first_name_en | varchar(100) | YES | | |
...@@ -150,6 +155,11 @@ app/Modules/HR/ ...@@ -150,6 +155,11 @@ app/Modules/HR/
| marital_status | varchar(20) | YES | | | | marital_status | varchar(20) | YES | | |
| religion | varchar(20) | YES | | Affects Ramadan schedule | | religion | varchar(20) | YES | | Affects Ramadan schedule |
| nationality | varchar(50) | NO | | Default: 'egyptian' | | nationality | varchar(50) | NO | | Default: 'egyptian' |
| education_qualification | varchar(255) | YES | | Degree/diploma name |
| education_specialization | varchar(255) | YES | | Field of study |
| education_graduation_year | year | YES | | |
| education_grade | varchar(100) | YES | | Grade/GPA |
| education_university | varchar(255) | YES | | Institution name |
| address | text | YES | | | | address | text | YES | | |
| emergency_contact_name | varchar(200) | YES | | | | emergency_contact_name | varchar(200) | YES | | |
| emergency_contact_phone | varchar(20) | YES | | | | emergency_contact_phone | varchar(20) | YES | | |
...@@ -160,6 +170,7 @@ app/Modules/HR/ ...@@ -160,6 +170,7 @@ app/Modules/HR/
| probation_end_date | date | YES | | | | probation_end_date | date | YES | | |
| probation_status | varchar(20) | YES | | Default: 'in_probation' | | probation_status | varchar(20) | YES | | Default: 'in_probation' |
| employment_type | varchar(20) | NO | | Default: 'full_time' | | employment_type | varchar(20) | NO | | Default: 'full_time' |
| workforce_type | varchar(30) | YES | | insured / part_time_freelance |
| employment_status | varchar(20) | NO | MUL | Default: 'active' | | employment_status | varchar(20) | NO | MUL | Default: 'active' |
| salary_structure_id | bigint unsigned | YES | MUL | FK to hr_salary_structures | | salary_structure_id | bigint unsigned | YES | MUL | FK to hr_salary_structures |
| basic_salary | decimal(15,2) | NO | | Default: 0.00 | | basic_salary | decimal(15,2) | NO | | Default: 0.00 |
...@@ -197,8 +208,20 @@ app/Modules/HR/ ...@@ -197,8 +208,20 @@ app/Modules/HR/
| branch_id | bigint unsigned | YES | MUL | FK to branches | | branch_id | bigint unsigned | YES | MUL | FK to branches |
| is_active | tinyint(1) | NO | MUL | | | is_active | tinyint(1) | NO | MUL | |
| sort_order | int unsigned | NO | | | | sort_order | int unsigned | NO | | |
| staffing_capacity | int unsigned | YES | | Overall authorized headcount |
| is_archived | tinyint(1) | NO | MUL | | | is_archived | tinyint(1) | NO | MUL | |
### 3.2b `hr_department_positions` Table
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| department_id | bigint unsigned | NO | MUL+UNI | FK to hr_departments |
| job_title_id | bigint unsigned | NO | MUL+UNI | FK to hr_job_titles |
| authorized_count | int unsigned | NO | | Default: 1 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | |
### 3.3 `hr_contracts` Table ### 3.3 `hr_contracts` Table
| Column | Type | Nullable | Key | Notes | | Column | Type | Nullable | Key | Notes |
...@@ -325,7 +348,7 @@ app/Modules/HR/ ...@@ -325,7 +348,7 @@ app/Modules/HR/
| id | bigint unsigned | NO | PRI | auto_increment | | id | bigint unsigned | NO | PRI | auto_increment |
| employee_profile_id | bigint unsigned | NO | MUL | | | employee_profile_id | bigint unsigned | NO | MUL | |
| loan_number | varchar(30) | NO | UNI | | | loan_number | varchar(30) | NO | UNI | |
| loan_type | varchar(30) | NO | | advance/emergency/personal | | loan_type | varchar(30) | NO | | advance/emergency/personal/financing_company/bank_loan |
| loan_amount | decimal(15,2) | NO | | | | loan_amount | decimal(15,2) | NO | | |
| installment_amount | decimal(15,2) | NO | | Monthly deduction | | installment_amount | decimal(15,2) | NO | | Monthly deduction |
| number_of_installments | int unsigned | NO | | | | number_of_installments | int unsigned | NO | | |
...@@ -333,6 +356,9 @@ app/Modules/HR/ ...@@ -333,6 +356,9 @@ app/Modules/HR/
| remaining_amount | decimal(15,2) | NO | | | | remaining_amount | decimal(15,2) | NO | | |
| interest_rate | decimal(5,2) | NO | | Default: 0.00 | | interest_rate | decimal(5,2) | NO | | Default: 0.00 |
| start_deduction_date | date | NO | | | | start_deduction_date | date | NO | | |
| external_entity_name | varchar(200) | YES | | Bank/financing company name |
| external_reference | varchar(100) | YES | | Contract/reference number |
| monthly_salary_impact | decimal(15,2) | YES | | Monthly salary deduction amount |
| status | varchar(20) | NO | MUL | pending/approved/active/completed/rejected | | status | varchar(20) | NO | MUL | pending/approved/active/completed/rejected |
| is_archived | tinyint(1) | NO | MUL | | | is_archived | tinyint(1) | NO | MUL | |
...@@ -391,6 +417,7 @@ app/Modules/HR/ ...@@ -391,6 +417,7 @@ app/Modules/HR/
| suspension_days | int unsigned | YES | | | | suspension_days | int unsigned | YES | | |
| effective_date | date | YES | | | | effective_date | date | YES | | |
| status | varchar(30) | NO | MUL | investigation/pending_decision/decided/appealed/final | | status | varchar(30) | NO | MUL | investigation/pending_decision/decided/appealed/final |
| investigation_result | text | YES | | Investigation result / applied penalty |
| appeal_text | text | YES | | | | appeal_text | text | YES | | |
| appeal_result | varchar(20) | YES | | accepted/rejected | | appeal_result | varchar(20) | YES | | accepted/rejected |
| is_archived | tinyint(1) | NO | MUL | | | is_archived | tinyint(1) | NO | MUL | |
...@@ -456,7 +483,7 @@ app/Modules/HR/ ...@@ -456,7 +483,7 @@ app/Modules/HR/
| Table | Purpose | | Table | Purpose |
|-------|---------| |-------|---------|
| hr_job_titles | Job title definitions | | hr_job_titles | Job title definitions (includes job_description_ar, job_description_en for formal job descriptions) |
| hr_leave_types | Leave type definitions (annual, sick, etc.) | | hr_leave_types | Leave type definitions (annual, sick, etc.) |
| hr_leave_balances | Per-employee per-year leave balance tracking | | hr_leave_balances | Per-employee per-year leave balance tracking |
| hr_loan_installments | Individual loan installment records | | hr_loan_installments | Individual loan installment records |
...@@ -473,7 +500,9 @@ app/Modules/HR/ ...@@ -473,7 +500,9 @@ app/Modules/HR/
| hr_holidays | Official holiday dates | | hr_holidays | Official holiday dates |
| hr_performance_cycles | Performance review cycles | | hr_performance_cycles | Performance review cycles |
| hr_performance_reviews | Individual performance reviews | | hr_performance_reviews | Individual performance reviews |
| hr_employee_documents | Uploaded employee documents | | hr_employee_documents | Uploaded employee documents (with validity_status: valid/expiring_soon/expired) |
| hr_biometric_devices | Biometric hardware devices (fingerprint/face/card readers) |
| hr_biometric_punches | Raw punch records from devices (processed → attendance records) |
--- ---
...@@ -495,6 +524,10 @@ app/Modules/HR/ ...@@ -495,6 +524,10 @@ app/Modules/HR/
f. Get loan installments due f. Get loan installments due
g. Get disciplinary deductions g. Get disciplinary deductions
h. Calculate absence deductions (basic/30 * absent_days) h. Calculate absence deductions (basic/30 * absent_days)
h2. Martyrs fund deduction (from system_config: hr.deduction.martyrs_fund_amount)
h3. Regular stamp duty (from system_config: hr.deduction.regular_stamp_duty)
h4. Additional stamp duty (from system_config: hr.deduction.additional_stamp_duty)
h5. External loan deductions (financing_company/bank_loan monthly_salary_impact)
i. Net = gross - all deductions i. Net = gross - all deductions
j. Persist: hr_payroll_runs + components_log + insurance_records + tax_records j. Persist: hr_payroll_runs + components_log + insurance_records + tax_records
k. Process loan installments k. Process loan installments
...@@ -629,7 +662,7 @@ Maternity protection: Cannot terminate during maternity or 1 year after (Art.91) ...@@ -629,7 +662,7 @@ Maternity protection: Cannot terminate during maternity or 1 year after (Art.91)
| Subsystem | Count | Prefix | Key Operations | | Subsystem | Count | Prefix | Key Operations |
|-----------|-------|--------|----------------| |-----------|-------|--------|----------------|
| Departments | 7 | /hr/departments | CRUD + archive | | Departments | 10 | /hr/departments | CRUD + archive + positions management |
| Job Titles | 6 | /hr/job-titles | CRUD + archive | | Job Titles | 6 | /hr/job-titles | CRUD + archive |
| Employee Profiles | 10 | /hr/employees | CRUD + archive + salary + search-json | | Employee Profiles | 10 | /hr/employees | CRUD + archive + salary + search-json |
| Contracts | 8 | /hr/contracts | CRUD + renew + terminate | | Contracts | 8 | /hr/contracts | CRUD + renew + terminate |
...@@ -728,6 +761,15 @@ Maternity protection: Cannot terminate during maternity or 1 year after (Art.91) ...@@ -728,6 +761,15 @@ Maternity protection: Cannot terminate during maternity or 1 year after (Art.91)
| hr.eos.after_5y_rate | End of service after 5 years rate (1.0) | | hr.eos.after_5y_rate | End of service after 5 years rate (1.0) |
| hr.eos.notice_under_10y | Notice period months < 10 years (2) | | hr.eos.notice_under_10y | Notice period months < 10 years (2) |
| hr.eos.notice_over_10y | Notice period months >= 10 years (3) | | hr.eos.notice_over_10y | Notice period months >= 10 years (3) |
| hr.deduction.martyrs_fund_amount | Martyrs fund monthly deduction (fixed amount) |
| hr.deduction.regular_stamp_duty | Regular stamp duty monthly deduction |
| hr.deduction.additional_stamp_duty | Additional stamp duty monthly deduction |
| hr.leave.annual_days_under_10y | Annual leave days for < 10 years service (default: 21) |
| hr.leave.annual_days_over_10y | Annual leave days for >= 10 years or age 50+ (default: 30) |
| hr.leave.maternity_days | Maternity leave days (default: 90) |
| hr.leave.maternity_max_career | Max maternity leaves in career (default: 3) |
| hr.leave.breastfeeding_daily_hours | Breastfeeding tolerance hours/day (default: 1) |
| hr.leave.unpaid_max_days | Max unpaid leave days (0 = unlimited) |
--- ---
...@@ -771,6 +813,92 @@ Maternity protection: Cannot terminate during maternity or 1 year after (Art.91) ...@@ -771,6 +813,92 @@ Maternity protection: Cannot terminate during maternity or 1 year after (Art.91)
--- ---
## 15.8 Biometric Integration
The biometric subsystem provides integration with physical biometric devices (fingerprint readers, face scanners, card readers) to automatically record attendance.
### Database Tables
**`hr_biometric_devices`** — Registered biometric hardware
| Column | Type | Notes |
|--------|------|-------|
| id | bigint unsigned | PRI, auto_increment |
| device_name | varchar(100) | NOT NULL |
| device_type | varchar(20) | NOT NULL, default 'fingerprint' (fingerprint/face/card) |
| ip_address | varchar(45) | nullable |
| port | int unsigned | nullable |
| serial_number | varchar(100) | nullable, used for device authentication |
| location_description | varchar(255) | nullable |
| branch_id | bigint unsigned | nullable, FK to branches |
| is_active | tinyint(1) | NOT NULL, default 1 |
| last_sync_at | datetime | nullable, updated on each punch received |
| created_at | timestamp | |
| updated_at | timestamp | |
**`hr_biometric_punches`** — Individual punch records from devices
| Column | Type | Notes |
|--------|------|-------|
| id | bigint unsigned | PRI, auto_increment |
| device_id | bigint unsigned | NOT NULL, FK to hr_biometric_devices |
| fingerprint_code | varchar(50) | NOT NULL, links to hr_employee_profiles.fingerprint_code |
| punch_time | datetime | NOT NULL |
| punch_type | varchar(10) | NOT NULL, default 'unknown' (in/out/unknown) |
| processed | tinyint(1) | NOT NULL, default 0 |
| attendance_id | bigint unsigned | nullable, FK to hr_attendance (after processing) |
| error_message | varchar(500) | nullable |
| created_at | timestamp | |
### Key Components
- **Controller**: `BiometricController` — CRUD for devices, punch listing, API endpoint for receiving punches, manual process-all trigger
- **Service**: `BiometricService` — processPunch(), processAllPending(), resolveEmployee(), determinePunchType()
- **Views**: `biometric/devices.php`, `biometric/punches.php`, `biometric/device_form.php`
### Integration Flow
```
Biometric Device → POST /hr/biometric/receive-punch (no auth, device serial auth)
→ BiometricService::processPunch()
→ Validate device by serial_number
→ Insert punch record
→ Resolve employee via fingerprint_code
→ Determine punch type (in/out) from current attendance state
→ Call AttendanceService::recordCheckIn() or recordCheckOut()
→ Mark punch as processed, link to attendance_id
```
### Permissions
| Key | Description |
|-----|-------------|
| hr.biometric.view | View biometric devices and punch records |
| hr.biometric.manage | Manage devices, trigger bulk processing |
### Routes (9 total)
| Method | Path | Handler | Auth | Permission |
|--------|------|---------|------|------------|
| GET | /hr/biometric/devices | devices | auth | hr.biometric.view |
| GET | /hr/biometric/devices/create | createDevice | auth | hr.biometric.manage |
| POST | /hr/biometric/devices | storeDevice | auth, csrf | hr.biometric.manage |
| GET | /hr/biometric/devices/{id}/edit | editDevice | auth | hr.biometric.manage |
| POST | /hr/biometric/devices/{id} | updateDevice | auth, csrf | hr.biometric.manage |
| POST | /hr/biometric/devices/{id}/delete | deleteDevice | auth, csrf | hr.biometric.manage |
| GET | /hr/biometric/punches | punches | auth | hr.biometric.view |
| POST | /hr/biometric/receive-punch | receivePunch | none | none (device serial auth) |
| POST | /hr/biometric/process-all | processAll | auth, csrf | hr.biometric.manage |
### Risk: API Endpoint Security
The `receivePunch` endpoint has NO authentication middleware. It relies solely on the device serial_number being valid and active. This is intentional for IoT device integration but means:
- Any entity knowing a valid serial can inject punches
- Network-level security (firewall, VPN) should protect this endpoint in production
- Rate limiting is not currently implemented
---
## 16. Known Patterns & Gotchas ## 16. Known Patterns & Gotchas
1. **Two employee entities**: `employees` (system user for login) and `hr_employee_profiles` (HR data for payroll). Linked by `employee_id` FK 1. **Two employee entities**: `employees` (system user for login) and `hr_employee_profiles` (HR data for payroll). Linked by `employee_id` FK
......
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