Commit 9342a5ee authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(hr): work schedules, job description cards, org hierarchy

Phase 3: Weekly work schedules
- Migration: hr_weekly_schedules table (per-employee per-day shift times + rest days)
- Seed: import 59 employees' weekly schedules from club's work schedule sheet
- Seed: 13 shift definitions (9-5, 3-11, 10-6, 12-8, etc.)

Phase 7B: Job description cards
- Migration: hr_job_descriptions table (purpose, authority, duties per job title)
- View: printable بطاقة الوصف الوظيفي matching club's official template
- Route: GET /hr/job-titles/{id}/description-card

Phase 8: Org structure hierarchy
- Migration sets parent_id=1 (EXEC) for all other departments
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f61ceb27
......@@ -135,6 +135,29 @@ class JobTitleController extends Controller
return $errors;
}
public function descriptionCard(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$jobTitle = $db->selectOne(
"SELECT * FROM hr_job_titles WHERE id = ? AND is_archived = 0",
[(int) $id]
);
if (!$jobTitle) {
return $this->redirect('/hr/job-titles')->withError('المسمى الوظيفي غير موجود');
}
$description = $db->selectOne(
"SELECT * FROM hr_job_descriptions WHERE job_title_id = ? AND is_archived = 0 ORDER BY id DESC LIMIT 1",
[(int) $id]
);
return $this->view('HR.Views.forms.job-description', [
'jobTitle' => $jobTitle,
'description' => $description,
]);
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
......
......@@ -23,6 +23,7 @@ return [
['GET', '/hr/job-titles/{id:\d+}/edit', 'HR\Controllers\JobTitleController@edit', ['auth'], 'hr.job_title.manage'],
['POST', '/hr/job-titles/{id:\d+}', 'HR\Controllers\JobTitleController@update', ['auth', 'csrf'], 'hr.job_title.manage'],
['POST', '/hr/job-titles/{id:\d+}/archive', 'HR\Controllers\JobTitleController@archive', ['auth', 'csrf'], 'hr.job_title.manage'],
['GET', '/hr/job-titles/{id:\d+}/description-card', 'HR\Controllers\JobTitleController@descriptionCard', ['auth'], 'hr.job_title.view'],
// ── Employee Profiles ──
['GET', '/hr/employees', 'HR\Controllers\EmployeeProfileController@index', ['auth'], 'hr.employee.view'],
......
<?php
/** @var array $jobTitle */
/** @var array|null $description */
$__template->layout('Layout.print');
$__template->section('title', 'بطاقة الوصف الوظيفي');
$__template->section('content');
?>
<div class="print-page" style="direction: rtl; text-align: right; font-family: 'Cairo', sans-serif; max-width: 700px; margin: 0 auto; padding: 40px;">
<div style="text-align: center; margin-bottom: 30px;">
<p style="margin: 0; font-size: 13px; color: #666;">H.R</p>
<h3 style="margin: 10px 0 5px;">شركة أوسي سبورت للخدمات الرياضية</h3>
<p style="margin: 0; color: #666;">نادي النادي فرع شيراتون</p>
<hr style="border: 1px solid #333; margin: 15px 0;">
<h2 style="margin: 15px 0;">بطاقة الوصف الوظيفي ( <?= e($jobTitle['name_ar'] ?? '') ?> )</h2>
</div>
<div style="font-size: 14px; line-height: 2;">
<h4 style="margin: 20px 0 10px; background: #f5f5f5; padding: 5px 10px;">أولاً — معلومات خاصة بالوظيفة:</h4>
<table style="width:100%; border-collapse: collapse; font-size: 14px;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd; width: 30%; font-weight: bold; background: #fafafa;">المسمى الوظيفي</td>
<td style="padding: 8px; border: 1px solid #ddd;"><?= e($jobTitle['name_ar'] ?? '') ?></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #fafafa;">الغرض الرئيسي من الوظيفة</td>
<td style="padding: 8px; border: 1px solid #ddd;"><?= e($description['purpose_ar'] ?? '..............................') ?></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #fafafa;">الجهة المسئولة عن الوظيفة</td>
<td style="padding: 8px; border: 1px solid #ddd;"><?= e($description['responsible_authority'] ?? '..............................') ?></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #fafafa;">العلاقة مع الوظائف الأخرى</td>
<td style="padding: 8px; border: 1px solid #ddd;"><?= e($description['relationships'] ?? 'مباشرة') ?></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #fafafa;">حدود الصلاحيات</td>
<td style="padding: 8px; border: 1px solid #ddd;"><?= e($description['authority_limits'] ?? '..............................') ?></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #fafafa;">المؤهل العلمي</td>
<td style="padding: 8px; border: 1px solid #ddd;"><?= e($description['required_qualification'] ?? '..............................') ?></td>
</tr>
</table>
<h4 style="margin: 25px 0 10px; background: #f5f5f5; padding: 5px 10px;">ثانياً — المهام الخاصة بواجبات ومسئوليات الوظيفة:</h4>
<?php if (!empty($description['duties_ar'])): ?>
<div style="padding: 10px; white-space: pre-line;"><?= e($description['duties_ar']) ?></div>
<?php else: ?>
<div style="padding: 10px;">
<p>* ..................................................................................................</p>
<p>* ..................................................................................................</p>
<p>* ..................................................................................................</p>
<p>* ..................................................................................................</p>
<p>* ..................................................................................................</p>
</div>
<?php endif; ?>
</div>
<div style="margin-top: 60px; display: flex; justify-content: space-between;">
<div style="text-align: center;">
<p style="font-weight: bold;">مدير الموارد البشرية</p>
<br><br>
<p>التوقيع: ..................</p>
</div>
<div style="text-align: center;">
<p style="font-weight: bold;">المدير التنفيذي</p>
<br><br>
<p>التوقيع: ..................</p>
</div>
</div>
</div>
<style>
@media print {
body { margin: 0; }
.print-page { padding: 20px !important; }
.no-print { display: none !important; }
}
</style>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS hr_weekly_schedules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_profile_id INT UNSIGNED NOT NULL,
day_of_week ENUM('saturday','sunday','monday','tuesday','wednesday','thursday','friday') NOT NULL,
shift_id INT UNSIGNED NULL,
start_time TIME NULL,
end_time TIME NULL,
is_rest_day TINYINT(1) NOT NULL DEFAULT 0,
notes VARCHAR(200) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_profile_id) REFERENCES hr_employee_profiles(id) ON DELETE CASCADE,
FOREIGN KEY (shift_id) REFERENCES hr_shifts(id) ON DELETE SET NULL,
UNIQUE KEY uk_emp_day (employee_profile_id, day_of_week, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_job_descriptions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
job_title_id INT UNSIGNED NOT NULL,
purpose_ar TEXT NULL,
responsible_authority VARCHAR(200) NULL,
relationships VARCHAR(200) NULL,
authority_limits TEXT NULL,
required_qualification VARCHAR(200) NULL,
duties_ar TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by INT UNSIGNED NULL,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
FOREIGN KEY (job_title_id) REFERENCES hr_job_titles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
UPDATE hr_departments SET parent_id = 1 WHERE department_code != 'EXEC' AND parent_id IS NULL AND is_archived = 0
",
'down' => "
DROP TABLE IF EXISTS hr_weekly_schedules;
DROP TABLE IF EXISTS hr_job_descriptions;
UPDATE hr_departments SET parent_id = NULL WHERE parent_id = 1
",
];
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$shifts = [
['name_ar' => 'وردية صباحية 9-5', 'start' => '09:00', 'end' => '17:00', 'hours' => 8],
['name_ar' => 'وردية مسائية 3-11', 'start' => '15:00', 'end' => '23:00', 'hours' => 8],
['name_ar' => 'وردية 10-6', 'start' => '10:00', 'end' => '18:00', 'hours' => 8],
['name_ar' => 'وردية 12-8', 'start' => '12:00', 'end' => '20:00', 'hours' => 8],
['name_ar' => 'وردية 8-3', 'start' => '08:00', 'end' => '15:00', 'hours' => 7],
['name_ar' => 'وردية 4-11', 'start' => '16:00', 'end' => '23:00', 'hours' => 7],
['name_ar' => 'وردية 2-10', 'start' => '14:00', 'end' => '22:00', 'hours' => 8],
['name_ar' => 'وردية 9-4', 'start' => '09:00', 'end' => '16:00', 'hours' => 7],
['name_ar' => 'وردية 5-11', 'start' => '17:00', 'end' => '23:00', 'hours' => 6],
['name_ar' => 'وردية طبية 6-10', 'start' => '18:00', 'end' => '22:00', 'hours' => 4],
['name_ar' => 'وردية 11-11', 'start' => '11:00', 'end' => '23:00', 'hours' => 12],
['name_ar' => 'وردية 1-9', 'start' => '13:00', 'end' => '21:00', 'hours' => 8],
['name_ar' => 'وردية 9-8', 'start' => '09:00', 'end' => '20:00', 'hours' => 11],
];
$inserted = 0;
foreach ($shifts as $shift) {
$exists = $db->selectOne(
"SELECT id FROM hr_shifts WHERE start_time = ? AND end_time = ? AND is_active = 1",
[$shift['start'] . ':00', $shift['end'] . ':00']
);
if (!$exists) {
$db->insert('hr_shifts', [
'name_ar' => $shift['name_ar'],
'start_time' => $shift['start'] . ':00',
'end_time' => $shift['end'] . ':00',
'working_hours' => number_format((float) $shift['hours'], 2, '.', ''),
'is_night_shift' => (int) ($shift['start'] >= '15:00' ? 1 : 0),
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
]);
$inserted++;
}
}
echo "Shift definitions: {$inserted} new shifts inserted\n";
};
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