Commit 26d47cc6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add complete HR module: employees, trainers, qualifications, availability

Full vertical slice with migrations, enums, models, services, Livewire CRUD,
views, permissions, routes, and sidebar navigation for the HR domain.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4a181920
<?php
namespace App\Domain\HR\Enums;
enum AvailabilityType: string
{
case Available = 'available';
case Unavailable = 'unavailable';
case Preferred = 'preferred';
public function label(): string
{
return match ($this) {
self::Available => 'متاح',
self::Unavailable => 'غير متاح',
self::Preferred => 'مفضّل',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum CompensationModel: string
{
case Salary = 'salary';
case Hourly = 'hourly';
case PerSession = 'per_session';
case PerGroup = 'per_group';
case PerPlayer = 'per_player';
case RevenueShare = 'revenue_share';
case Contract = 'contract';
case Hybrid = 'hybrid';
public function label(): string
{
return match ($this) {
self::Salary => 'راتب ثابت',
self::Hourly => 'بالساعة',
self::PerSession => 'بالحصة',
self::PerGroup => 'بالمجموعة',
self::PerPlayer => 'باللاعب',
self::RevenueShare => 'نسبة من الإيرادات',
self::Contract => 'عقد محدد',
self::Hybrid => 'مختلط',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum EmployeeStatus: string
{
case Active = 'active';
case OnLeave = 'on_leave';
case Suspended = 'suspended';
case Terminated = 'terminated';
case Resigned = 'resigned';
public function label(): string
{
return match ($this) {
self::Active => 'نشط',
self::OnLeave => 'في إجازة',
self::Suspended => 'موقوف',
self::Terminated => 'منتهي الخدمة',
self::Resigned => 'مستقيل',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum EmploymentType: string
{
case FullTime = 'full_time';
case PartTime = 'part_time';
case Contract = 'contract';
case Intern = 'intern';
case Volunteer = 'volunteer';
public function label(): string
{
return match ($this) {
self::FullTime => 'دوام كامل',
self::PartTime => 'دوام جزئي',
self::Contract => 'عقد',
self::Intern => 'متدرب',
self::Volunteer => 'متطوع',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum SalaryFrequency: string
{
case Monthly = 'monthly';
case Biweekly = 'biweekly';
case Weekly = 'weekly';
case Daily = 'daily';
case Hourly = 'hourly';
public function label(): string
{
return match ($this) {
self::Monthly => 'شهري',
self::Biweekly => 'نصف شهري',
self::Weekly => 'أسبوعي',
self::Daily => 'يومي',
self::Hourly => 'بالساعة',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum TrainerStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case OnLeave = 'on_leave';
case Suspended = 'suspended';
public function label(): string
{
return match ($this) {
self::Active => 'نشط',
self::Inactive => 'غير نشط',
self::OnLeave => 'في إجازة',
self::Suspended => 'موقوف',
};
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\EmployeeStatus;
use App\Domain\HR\Enums\EmploymentType;
use App\Domain\HR\Enums\SalaryFrequency;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class Employee extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'person_id',
'user_id',
'employee_number',
'department',
'position',
'employment_type',
'start_date',
'end_date',
'salary_amount',
'salary_frequency',
'working_hours_per_week',
'branch_id',
'manager_id',
'status',
'termination_reason',
'notes',
'metadata',
'created_by',
'updated_by',
];
protected $casts = [
'employment_type' => EmploymentType::class,
'status' => EmployeeStatus::class,
'salary_frequency' => SalaryFrequency::class,
'salary_amount' => 'integer',
'working_hours_per_week' => 'decimal:1',
'start_date' => 'date',
'end_date' => 'date',
'metadata' => 'array',
];
// --- Relationships ---
public function person(): BelongsTo
{
return $this->belongsTo(\App\Domain\Identity\Models\Person::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
public function branch(): BelongsTo
{
return $this->belongsTo(\App\Domain\Identity\Models\Branch::class);
}
public function manager(): BelongsTo
{
return $this->belongsTo(self::class, 'manager_id');
}
public function directReports(): HasMany
{
return $this->hasMany(self::class, 'manager_id');
}
public function trainer(): HasOne
{
return $this->hasOne(Trainer::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'updated_by');
}
// --- Scopes ---
public function scopeActive(Builder $query): Builder
{
return $query->where('status', EmployeeStatus::Active->value);
}
public function scopeForBranch(Builder $query, ?int $branchId): Builder
{
return $query->where(function (Builder $q) use ($branchId) {
$q->whereNull('branch_id')
->orWhere('branch_id', $branchId);
});
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\CompensationModel;
use App\Domain\HR\Enums\TrainerStatus;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Trainer extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'employee_id',
'trainer_number',
'bio',
'bio_ar',
'specializations',
'sports',
'max_daily_sessions',
'max_weekly_hours',
'compensation_model',
'hourly_rate',
'session_rate',
'group_rate',
'player_rate',
'revenue_share_percent',
'rating',
'total_sessions_conducted',
'status',
];
protected $casts = [
'compensation_model' => CompensationModel::class,
'status' => TrainerStatus::class,
'specializations' => 'array',
'sports' => 'array',
'hourly_rate' => 'integer',
'session_rate' => 'integer',
'group_rate' => 'integer',
'player_rate' => 'integer',
'max_daily_sessions' => 'integer',
'max_weekly_hours' => 'decimal:1',
'revenue_share_percent' => 'decimal:2',
'rating' => 'decimal:2',
'total_sessions_conducted' => 'integer',
];
// --- Relationships ---
public function employee(): BelongsTo
{
return $this->belongsTo(Employee::class);
}
public function qualifications(): HasMany
{
return $this->hasMany(TrainerQualification::class);
}
public function availabilities(): HasMany
{
return $this->hasMany(TrainerAvailability::class);
}
public function assignments(): HasMany
{
return $this->hasMany(\App\Domain\Scheduling\Models\Assignment::class, 'user_id', 'employee_id')
->whereHas('employee', fn ($q) => $q->whereColumn('employees.user_id', 'assignments.user_id'));
}
// --- Scopes ---
public function scopeActive(Builder $query): Builder
{
return $query->where('status', TrainerStatus::Active->value);
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\AvailabilityType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TrainerAvailability extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $table = 'trainer_availability';
protected $fillable = [
'trainer_id',
'type',
'day_of_week',
'specific_date',
'start_time',
'end_time',
'reason',
];
protected $casts = [
'type' => AvailabilityType::class,
'specific_date' => 'date',
'day_of_week' => 'integer',
];
// --- Relationships ---
public function trainer(): BelongsTo
{
return $this->belongsTo(Trainer::class);
}
}
<?php
namespace App\Domain\HR\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TrainerQualification extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'trainer_id',
'name',
'issuer',
'issue_date',
'expiry_date',
'document_path',
'is_verified',
'verified_by',
'verified_at',
];
protected $casts = [
'issue_date' => 'date',
'expiry_date' => 'date',
'is_verified' => 'boolean',
'verified_at' => 'datetime',
];
// --- Relationships ---
public function trainer(): BelongsTo
{
return $this->belongsTo(Trainer::class);
}
public function verifier(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'verified_by');
}
}
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Models\Employee;
use App\Models\User;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
private const VALID_TRANSITIONS = [
'active' => ['on_leave', 'suspended', 'terminated', 'resigned'],
'on_leave' => ['active', 'terminated', 'resigned'],
'suspended' => ['active', 'terminated', 'resigned'],
'terminated' => [],
'resigned' => [],
];
public function create(array $data, User $actor): Employee
{
return DB::transaction(function () use ($data, $actor) {
$data['employee_number'] = $this->generateEmployeeNumber($data['academy_id'], $data['branch_id'] ?? null);
$data['created_by'] = $actor->id;
return Employee::create($data);
});
}
public function update(Employee $employee, array $data, User $actor): Employee
{
return DB::transaction(function () use ($employee, $data, $actor) {
$data['updated_by'] = $actor->id;
$employee->update($data);
return $employee->fresh();
});
}
public function changeStatus(Employee $employee, string $newStatus, ?string $reason, User $actor): Employee
{
$currentStatus = $employee->status->value;
$allowed = self::VALID_TRANSITIONS[$currentStatus] ?? [];
if (! in_array($newStatus, $allowed)) {
throw new InvalidStatusTransitionException(
"لا يمكن تغيير حالة الموظف من '{$currentStatus}' إلى '{$newStatus}'"
);
}
return DB::transaction(function () use ($employee, $newStatus, $reason, $actor) {
$updateData = [
'status' => $newStatus,
'updated_by' => $actor->id,
];
if (in_array($newStatus, ['terminated', 'resigned'])) {
$updateData['end_date'] = now()->toDateString();
$updateData['termination_reason'] = $reason;
}
$employee->update($updateData);
return $employee->fresh();
});
}
private function generateEmployeeNumber(int $academyId, ?int $branchId): string
{
$lastNumber = Employee::withoutGlobalScopes()
->where('academy_id', $academyId)
->max('id') ?? 0;
$seq = str_pad($lastNumber + 1, 4, '0', STR_PAD_LEFT);
return "EMP-{$seq}";
}
}
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Models\Employee;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAvailability;
use App\Domain\HR\Models\TrainerQualification;
use App\Models\User;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use Illuminate\Support\Facades\DB;
class TrainerService
{
private const VALID_TRANSITIONS = [
'active' => ['inactive', 'on_leave', 'suspended'],
'inactive' => ['active'],
'on_leave' => ['active', 'suspended'],
'suspended' => ['active', 'inactive'],
];
public function create(Employee $employee, array $data, User $actor): Trainer
{
return DB::transaction(function () use ($employee, $data) {
$data['academy_id'] = $employee->academy_id;
$data['employee_id'] = $employee->id;
$data['trainer_number'] = $this->generateTrainerNumber($employee->academy_id);
return Trainer::create($data);
});
}
public function update(Trainer $trainer, array $data, User $actor): Trainer
{
return DB::transaction(function () use ($trainer, $data) {
$trainer->update($data);
return $trainer->fresh();
});
}
public function changeStatus(Trainer $trainer, string $newStatus, User $actor): Trainer
{
$currentStatus = $trainer->status->value;
$allowed = self::VALID_TRANSITIONS[$currentStatus] ?? [];
if (! in_array($newStatus, $allowed)) {
throw new InvalidStatusTransitionException(
"لا يمكن تغيير حالة المدرب من '{$currentStatus}' إلى '{$newStatus}'"
);
}
return DB::transaction(function () use ($trainer, $newStatus) {
$trainer->update(['status' => $newStatus]);
return $trainer->fresh();
});
}
public function addQualification(Trainer $trainer, array $data): TrainerQualification
{
$data['trainer_id'] = $trainer->id;
$data['created_at'] = now();
return TrainerQualification::create($data);
}
public function removeQualification(TrainerQualification $qualification): void
{
$qualification->delete();
}
public function verifyQualification(TrainerQualification $qualification, User $verifier): void
{
$qualification->update([
'is_verified' => true,
'verified_by' => $verifier->id,
'verified_at' => now(),
]);
}
public function setAvailability(Trainer $trainer, array $slots): void
{
DB::transaction(function () use ($trainer, $slots) {
$trainer->availabilities()->delete();
$now = now();
$records = array_map(fn (array $slot) => [
'trainer_id' => $trainer->id,
'type' => $slot['type'],
'day_of_week' => $slot['day_of_week'] ?? null,
'specific_date' => $slot['specific_date'] ?? null,
'start_time' => $slot['start_time'],
'end_time' => $slot['end_time'],
'reason' => $slot['reason'] ?? null,
'created_at' => $now,
], $slots);
if (! empty($records)) {
TrainerAvailability::insert($records);
}
});
}
private function generateTrainerNumber(int $academyId): string
{
$lastNumber = Trainer::withoutGlobalScopes()
->where('academy_id', $academyId)
->max('id') ?? 0;
$seq = str_pad($lastNumber + 1, 4, '0', STR_PAD_LEFT);
return "TRN-{$seq}";
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\EmployeeStatus;
use App\Domain\HR\Enums\EmploymentType;
use App\Domain\HR\Enums\SalaryFrequency;
use App\Domain\HR\Models\Employee;
use App\Domain\HR\Services\EmployeeService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Models\Person;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('components.layouts.app')]
#[Title('بيانات الموظف')]
class EmployeeForm extends Component
{
public ?Employee $employee = null;
public bool $editing = false;
// Person selection
public string $personSearch = '';
public ?int $personId = null;
public string $selectedPersonName = '';
// Form fields
public string $department = '';
public string $position = '';
public string $employmentType = 'full_time';
public string $startDate = '';
public ?string $endDate = null;
public ?string $salaryAmount = null;
public string $salaryFrequency = '';
public ?string $workingHoursPerWeek = null;
public string $branchId = '';
public string $managerId = '';
public string $notes = '';
public function mount(?Employee $employee = null): void
{
if ($employee && $employee->exists) {
$this->authorize('employees.update');
$this->employee = $employee;
$this->editing = true;
$this->personId = $employee->person_id;
$this->selectedPersonName = $employee->person->name_ar ?? '';
$this->department = $employee->department ?? '';
$this->position = $employee->position ?? '';
$this->employmentType = $employee->employment_type->value;
$this->startDate = $employee->start_date->format('Y-m-d');
$this->endDate = $employee->end_date?->format('Y-m-d');
$this->salaryAmount = $employee->salary_amount ? (string) ($employee->salary_amount / 100) : null;
$this->salaryFrequency = $employee->salary_frequency?->value ?? '';
$this->workingHoursPerWeek = $employee->working_hours_per_week ? (string) $employee->working_hours_per_week : null;
$this->branchId = (string) ($employee->branch_id ?? '');
$this->managerId = (string) ($employee->manager_id ?? '');
$this->notes = $employee->notes ?? '';
} else {
$this->authorize('employees.create');
$this->startDate = now()->format('Y-m-d');
}
}
public function rules(): array
{
return [
'personId' => 'required|exists:people,id',
'department' => 'nullable|string|max:30',
'position' => 'nullable|string|max:50',
'employmentType' => 'required|in:' . implode(',', array_column(EmploymentType::cases(), 'value')),
'startDate' => 'required|date',
'endDate' => 'nullable|date|after_or_equal:startDate',
'salaryAmount' => 'nullable|numeric|min:0',
'salaryFrequency' => 'nullable|in:' . implode(',', array_column(SalaryFrequency::cases(), 'value')),
'workingHoursPerWeek' => 'nullable|numeric|min:0|max:168',
'branchId' => 'nullable|exists:branches,id',
'managerId' => 'nullable|exists:employees,id',
'notes' => 'nullable|string|max:2000',
];
}
public function messages(): array
{
return [
'personId.required' => 'يجب اختيار شخص',
'personId.exists' => 'الشخص المختار غير موجود',
'employmentType.required' => 'نوع التوظيف مطلوب',
'employmentType.in' => 'نوع التوظيف غير صالح',
'startDate.required' => 'تاريخ البداية مطلوب',
'startDate.date' => 'تاريخ البداية غير صالح',
'endDate.after_or_equal' => 'تاريخ الانتهاء يجب أن يكون بعد تاريخ البداية',
'salaryAmount.numeric' => 'الراتب يجب أن يكون رقماً',
'salaryFrequency.in' => 'دورة الراتب غير صالحة',
'workingHoursPerWeek.numeric' => 'ساعات العمل يجب أن تكون رقماً',
];
}
public function save(EmployeeService $service): void
{
$this->validate();
$data = [
'person_id' => $this->personId,
'department' => $this->department ?: null,
'position' => $this->position ?: null,
'employment_type' => $this->employmentType,
'start_date' => $this->startDate,
'end_date' => $this->endDate ?: null,
'salary_amount' => $this->salaryAmount ? (int) round((float) $this->salaryAmount * 100) : null,
'salary_frequency' => $this->salaryFrequency ?: null,
'working_hours_per_week' => $this->workingHoursPerWeek ?: null,
'branch_id' => $this->branchId ?: null,
'manager_id' => $this->managerId ?: null,
'notes' => $this->notes ?: null,
];
try {
if ($this->editing) {
$service->update($this->employee, $data, auth()->user());
session()->flash('success', __('تم تحديث بيانات الموظف بنجاح'));
} else {
$data['academy_id'] = app('current_academy')->id;
$service->create($data, auth()->user());
session()->flash('success', __('تم إضافة الموظف بنجاح'));
}
$this->redirect(route('employees.list'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function selectPerson(int $id): void
{
$person = Person::find($id);
if ($person) {
$this->personId = $person->id;
$this->selectedPersonName = $person->name_ar;
$this->personSearch = '';
}
}
public function render()
{
$searchResults = [];
if (strlen($this->personSearch) >= 2) {
$searchResults = Person::where(function ($q) {
$q->where('name_ar', 'ilike', "%{$this->personSearch}%")
->orWhere('name', 'ilike', "%{$this->personSearch}%")
->orWhere('phone', 'ilike', "%{$this->personSearch}%")
->orWhere('national_id', 'ilike', "%{$this->personSearch}%");
})->limit(10)->get();
}
return view('livewire.hr.employee-form', [
'searchResults' => $searchResults,
'employmentTypes' => EmploymentType::cases(),
'salaryFrequencies' => SalaryFrequency::cases(),
'branches' => Branch::orderBy('name_ar')->get(),
'managers' => Employee::with('person')->active()->get(),
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\EmployeeStatus;
use App\Domain\HR\Enums\EmploymentType;
use App\Domain\HR\Models\Employee;
use App\Domain\HR\Services\EmployeeService;
use App\Domain\Identity\Services\PermissionService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('components.layouts.app')]
#[Title('الموظفين')]
class EmployeeList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $employmentType = '';
#[Url]
public string $branchId = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedEmploymentType(): void
{
$this->resetPage();
}
public function updatedBranchId(): void
{
$this->resetPage();
}
public function delete(string $uuid, EmployeeService $service): void
{
$this->authorize('employees.delete');
$employee = Employee::where('uuid', $uuid)->firstOrFail();
$employee->delete();
session()->flash('success', __('تم حذف الموظف بنجاح'));
}
public function render()
{
$query = Employee::with(['person', 'branch'])
->latest();
$query = PermissionService::applyScope($query, auth()->user(), 'employees.list');
if ($this->search) {
$search = $this->search;
$query->where(function ($q) use ($search) {
$q->where('employee_number', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('national_id', 'ilike', "%{$search}%");
});
});
}
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->employmentType) {
$query->where('employment_type', $this->employmentType);
}
if ($this->branchId) {
$query->where('branch_id', $this->branchId);
}
return view('livewire.hr.employee-list', [
'employees' => $query->paginate(20),
'statuses' => EmployeeStatus::cases(),
'employmentTypes' => EmploymentType::cases(),
'branches' => \App\Domain\Identity\Models\Branch::orderBy('name_ar')->get(),
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\CompensationModel;
use App\Domain\HR\Models\Employee;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\TrainerService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('components.layouts.app')]
#[Title('بيانات المدرب')]
class TrainerForm extends Component
{
public ?Trainer $trainer = null;
public bool $editing = false;
// Employee selection
public string $employeeSearch = '';
public ?int $employeeId = null;
public string $selectedEmployeeName = '';
// Form fields
public string $bio = '';
public string $bioAr = '';
public array $specializations = [];
public array $sports = [];
public ?string $maxDailySessions = null;
public ?string $maxWeeklyHours = null;
public string $compensationModel = 'salary';
public ?string $hourlyRate = null;
public ?string $sessionRate = null;
public ?string $groupRate = null;
public ?string $playerRate = null;
public ?string $revenueSharePercent = null;
// Qualifications
public array $qualifications = [];
public string $newQualName = '';
public string $newQualIssuer = '';
public ?string $newQualIssueDate = null;
public ?string $newQualExpiryDate = null;
public function mount(?Trainer $trainer = null): void
{
if ($trainer && $trainer->exists) {
$this->authorize('trainers.update');
$this->trainer = $trainer->load(['employee.person', 'qualifications']);
$this->editing = true;
$this->employeeId = $trainer->employee_id;
$this->selectedEmployeeName = $trainer->employee->person->name_ar ?? '';
$this->bio = $trainer->bio ?? '';
$this->bioAr = $trainer->bio_ar ?? '';
$this->specializations = $trainer->specializations ?? [];
$this->sports = $trainer->sports ?? [];
$this->maxDailySessions = $trainer->max_daily_sessions ? (string) $trainer->max_daily_sessions : null;
$this->maxWeeklyHours = $trainer->max_weekly_hours ? (string) $trainer->max_weekly_hours : null;
$this->compensationModel = $trainer->compensation_model->value;
$this->hourlyRate = $trainer->hourly_rate ? (string) ($trainer->hourly_rate / 100) : null;
$this->sessionRate = $trainer->session_rate ? (string) ($trainer->session_rate / 100) : null;
$this->groupRate = $trainer->group_rate ? (string) ($trainer->group_rate / 100) : null;
$this->playerRate = $trainer->player_rate ? (string) ($trainer->player_rate / 100) : null;
$this->revenueSharePercent = $trainer->revenue_share_percent ? (string) $trainer->revenue_share_percent : null;
$this->qualifications = $trainer->qualifications->map(fn ($q) => [
'id' => $q->id,
'name' => $q->name,
'issuer' => $q->issuer,
'issue_date' => $q->issue_date?->format('Y-m-d'),
'expiry_date' => $q->expiry_date?->format('Y-m-d'),
'is_verified' => $q->is_verified,
])->toArray();
} else {
$this->authorize('trainers.create');
}
}
public function rules(): array
{
return [
'employeeId' => 'required|exists:employees,id',
'bio' => 'nullable|string|max:2000',
'bioAr' => 'nullable|string|max:2000',
'compensationModel' => 'required|in:' . implode(',', array_column(CompensationModel::cases(), 'value')),
'maxDailySessions' => 'nullable|integer|min:1|max:20',
'maxWeeklyHours' => 'nullable|numeric|min:1|max:168',
'hourlyRate' => 'nullable|numeric|min:0',
'sessionRate' => 'nullable|numeric|min:0',
'groupRate' => 'nullable|numeric|min:0',
'playerRate' => 'nullable|numeric|min:0',
'revenueSharePercent' => 'nullable|numeric|min:0|max:100',
];
}
public function messages(): array
{
return [
'employeeId.required' => 'يجب اختيار موظف',
'employeeId.exists' => 'الموظف المختار غير موجود',
'compensationModel.required' => 'نموذج التعويض مطلوب',
'compensationModel.in' => 'نموذج التعويض غير صالح',
'maxDailySessions.integer' => 'الحد الأقصى للحصص يجب أن يكون رقماً صحيحاً',
'maxWeeklyHours.numeric' => 'ساعات العمل الأسبوعية يجب أن تكون رقماً',
'revenueSharePercent.max' => 'نسبة الإيرادات لا يمكن أن تتجاوز 100%',
];
}
public function save(TrainerService $service): void
{
$this->validate();
$data = [
'bio' => $this->bio ?: null,
'bio_ar' => $this->bioAr ?: null,
'specializations' => $this->specializations,
'sports' => $this->sports,
'max_daily_sessions' => $this->maxDailySessions ? (int) $this->maxDailySessions : null,
'max_weekly_hours' => $this->maxWeeklyHours ?: null,
'compensation_model' => $this->compensationModel,
'hourly_rate' => $this->hourlyRate ? (int) round((float) $this->hourlyRate * 100) : null,
'session_rate' => $this->sessionRate ? (int) round((float) $this->sessionRate * 100) : null,
'group_rate' => $this->groupRate ? (int) round((float) $this->groupRate * 100) : null,
'player_rate' => $this->playerRate ? (int) round((float) $this->playerRate * 100) : null,
'revenue_share_percent' => $this->revenueSharePercent ?: null,
];
try {
if ($this->editing) {
$service->update($this->trainer, $data, auth()->user());
session()->flash('success', __('تم تحديث بيانات المدرب بنجاح'));
} else {
$employee = Employee::findOrFail($this->employeeId);
$service->create($employee, $data, auth()->user());
session()->flash('success', __('تم إضافة المدرب بنجاح'));
}
$this->redirect(route('trainers.list'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function addQualification(): void
{
if (! $this->newQualName) {
return;
}
$this->qualifications[] = [
'id' => null,
'name' => $this->newQualName,
'issuer' => $this->newQualIssuer,
'issue_date' => $this->newQualIssueDate,
'expiry_date' => $this->newQualExpiryDate,
'is_verified' => false,
];
$this->newQualName = '';
$this->newQualIssuer = '';
$this->newQualIssueDate = null;
$this->newQualExpiryDate = null;
}
public function removeQualification(int $index): void
{
unset($this->qualifications[$index]);
$this->qualifications = array_values($this->qualifications);
}
public function selectEmployee(int $id): void
{
$employee = Employee::with('person')->find($id);
if ($employee) {
$this->employeeId = $employee->id;
$this->selectedEmployeeName = $employee->person->name_ar;
$this->employeeSearch = '';
}
}
public function render()
{
$searchResults = [];
if (strlen($this->employeeSearch) >= 2) {
$searchResults = Employee::with('person')
->active()
->doesntHave('trainer')
->whereHas('person', function ($q) {
$q->where('name_ar', 'ilike', "%{$this->employeeSearch}%")
->orWhere('name', 'ilike', "%{$this->employeeSearch}%");
})
->limit(10)->get();
}
return view('livewire.hr.trainer-form', [
'searchResults' => $searchResults,
'compensationModels' => CompensationModel::cases(),
'activities' => \App\Domain\Training\Models\Activity::where('is_active', true)->orderBy('name_ar')->get(),
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\CompensationModel;
use App\Domain\HR\Enums\TrainerStatus;
use App\Domain\HR\Models\Trainer;
use App\Domain\Identity\Services\PermissionService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('components.layouts.app')]
#[Title('المدربين')]
class TrainerList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $compensationModel = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedCompensationModel(): void
{
$this->resetPage();
}
public function render()
{
$query = Trainer::with(['employee.person', 'employee.branch'])
->latest();
$query = PermissionService::applyScope($query, auth()->user(), 'trainers.list');
if ($this->search) {
$search = $this->search;
$query->where(function ($q) use ($search) {
$q->where('trainer_number', 'ilike', "%{$search}%")
->orWhereHas('employee.person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
});
});
}
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->compensationModel) {
$query->where('compensation_model', $this->compensationModel);
}
return view('livewire.hr.trainer-list', [
'trainers' => $query->paginate(20),
'statuses' => TrainerStatus::cases(),
'compensationModels' => CompensationModel::cases(),
]);
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('employees', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('person_id')->constrained('people');
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('employee_number', 20);
$table->string('department', 30)->nullable();
$table->string('position', 50)->nullable();
$table->string('employment_type', 20);
$table->date('start_date');
$table->date('end_date')->nullable();
$table->bigInteger('salary_amount')->nullable();
$table->string('salary_frequency', 20)->nullable();
$table->decimal('working_hours_per_week', 4, 1)->nullable();
$table->foreignId('branch_id')->nullable()->constrained('branches')->nullOnDelete();
$table->unsignedBigInteger('manager_id')->nullable();
$table->string('status', 20)->default('active');
$table->text('termination_reason')->nullable();
$table->text('notes')->nullable();
$table->jsonb('metadata')->default('{}');
$table->foreignId('created_by')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->foreign('manager_id')->references('id')->on('employees')->nullOnDelete();
$table->unique(['academy_id', 'person_id']);
$table->unique(['academy_id', 'employee_number']);
$table->index(['academy_id', 'status']);
$table->index('branch_id');
});
DB::statement("ALTER TABLE employees ADD CONSTRAINT employees_employment_type_check CHECK (employment_type IN ('full_time', 'part_time', 'contract', 'intern', 'volunteer'))");
DB::statement("ALTER TABLE employees ADD CONSTRAINT employees_salary_frequency_check CHECK (salary_frequency IS NULL OR salary_frequency IN ('monthly', 'biweekly', 'weekly', 'daily', 'hourly'))");
DB::statement("ALTER TABLE employees ADD CONSTRAINT employees_status_check CHECK (status IN ('active', 'on_leave', 'suspended', 'terminated', 'resigned'))");
}
public function down(): void
{
Schema::dropIfExists('employees');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('trainers', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('employee_id')->unique()->constrained('employees');
$table->string('trainer_number', 20);
$table->text('bio')->nullable();
$table->text('bio_ar')->nullable();
$table->jsonb('specializations')->default('[]');
$table->jsonb('sports')->default('[]');
$table->integer('max_daily_sessions')->nullable();
$table->decimal('max_weekly_hours', 4, 1)->nullable();
$table->string('compensation_model', 20);
$table->bigInteger('hourly_rate')->nullable();
$table->bigInteger('session_rate')->nullable();
$table->bigInteger('group_rate')->nullable();
$table->bigInteger('player_rate')->nullable();
$table->decimal('revenue_share_percent', 5, 2)->nullable();
$table->decimal('rating', 3, 2)->nullable();
$table->integer('total_sessions_conducted')->default(0);
$table->string('status', 20)->default('active');
$table->timestamps();
$table->unique(['academy_id', 'trainer_number']);
$table->index(['academy_id', 'status']);
});
DB::statement("ALTER TABLE trainers ADD CONSTRAINT trainers_compensation_model_check CHECK (compensation_model IN ('salary', 'hourly', 'per_session', 'per_group', 'per_player', 'revenue_share', 'contract', 'hybrid'))");
DB::statement("ALTER TABLE trainers ADD CONSTRAINT trainers_status_check CHECK (status IN ('active', 'inactive', 'on_leave', 'suspended'))");
}
public function down(): void
{
Schema::dropIfExists('trainers');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('trainer_qualifications', function (Blueprint $table) {
$table->id();
$table->foreignId('trainer_id')->constrained('trainers')->cascadeOnDelete();
$table->string('name', 100);
$table->string('issuer', 100)->nullable();
$table->date('issue_date')->nullable();
$table->date('expiry_date')->nullable();
$table->string('document_path', 500)->nullable();
$table->boolean('is_verified')->default(false);
$table->foreignId('verified_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('verified_at')->nullable();
$table->timestamp('created_at')->nullable();
$table->index('trainer_id');
$table->index('expiry_date');
});
}
public function down(): void
{
Schema::dropIfExists('trainer_qualifications');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('trainer_availability', function (Blueprint $table) {
$table->id();
$table->foreignId('trainer_id')->constrained('trainers')->cascadeOnDelete();
$table->string('type', 20);
$table->smallInteger('day_of_week')->nullable();
$table->date('specific_date')->nullable();
$table->time('start_time');
$table->time('end_time');
$table->string('reason', 200)->nullable();
$table->timestamp('created_at')->nullable();
$table->index('trainer_id');
$table->index(['trainer_id', 'day_of_week']);
});
DB::statement("ALTER TABLE trainer_availability ADD CONSTRAINT trainer_availability_type_check CHECK (type IN ('available', 'unavailable', 'preferred'))");
}
public function down(): void
{
Schema::dropIfExists('trainer_availability');
}
};
This diff is collapsed.
@php @php
$navigation = [ $navigation = [
['label' => 'لوحة التحكم', 'route' => 'dashboard', 'icon' => 'home', 'permission' => 'dashboard.view'], ['label' => 'لوحة التحكم', 'route' => 'dashboard', 'icon' => 'home', 'permission' => 'dashboard.view'],
['label' => 'بوابة ولي الأمر', 'route' => 'guardian.dashboard', 'icon' => 'user-group', 'permission' => 'dashboard.view', 'role' => 'parent'],
['label' => 'مكتب الاستقبال', 'route' => 'receptionist.dashboard', 'icon' => 'reception', 'permission' => 'participants.list'], ['label' => 'مكتب الاستقبال', 'route' => 'receptionist.dashboard', 'icon' => 'reception', 'permission' => 'participants.list'],
['section' => 'المشاركين', 'items' => [ ['section' => 'المشاركين', 'items' => [
...@@ -27,6 +28,11 @@ ...@@ -27,6 +28,11 @@
['label' => 'لوحة المدرب', 'route' => 'trainer.dashboard', 'icon' => 'user', 'permission' => 'attendance.mark'], ['label' => 'لوحة المدرب', 'route' => 'trainer.dashboard', 'icon' => 'user', 'permission' => 'attendance.mark'],
]], ]],
['section' => 'الموارد البشرية', 'items' => [
['label' => 'الموظفين', 'route' => 'employees.list', 'icon' => 'briefcase', 'permission' => 'employees.list'],
['label' => 'المدربين', 'route' => 'trainers.list', 'icon' => 'academic-cap', 'permission' => 'trainers.list'],
]],
['section' => 'المالية', 'items' => [ ['section' => 'المالية', 'items' => [
['label' => 'النظرة المالية', 'route' => 'financial.overview', 'icon' => 'chart-bar', 'permission' => 'invoices.list'], ['label' => 'النظرة المالية', 'route' => 'financial.overview', 'icon' => 'chart-bar', 'permission' => 'invoices.list'],
['label' => 'الفواتير', 'route' => 'invoices.list', 'icon' => 'document', 'permission' => 'invoices.list'], ['label' => 'الفواتير', 'route' => 'invoices.list', 'icon' => 'document', 'permission' => 'invoices.list'],
...@@ -69,7 +75,7 @@ ...@@ -69,7 +75,7 @@
['label' => 'التقارير', 'route' => 'reports.view', 'icon' => 'chart-bar', 'permission' => 'reports.view'], ['label' => 'التقارير', 'route' => 'reports.view', 'icon' => 'chart-bar', 'permission' => 'reports.view'],
['label' => 'المستخدمين', 'route' => 'users.list', 'icon' => 'users', 'permission' => 'users.list'], ['label' => 'المستخدمين', 'route' => 'users.list', 'icon' => 'users', 'permission' => 'users.list'],
['label' => 'الأدوار', 'route' => 'roles.list', 'icon' => 'shield-check', 'permission' => 'roles.list'], ['label' => 'الأدوار', 'route' => 'roles.list', 'icon' => 'shield-check', 'permission' => 'roles.list'],
['label' => 'الفروع', 'route' => 'branches.list', 'icon' => 'building-office', 'permission' => 'settings.manage'], ['label' => 'الفروع', 'route' => 'branches.list', 'icon' => 'building-office', 'permission' => 'branches.list'],
['label' => 'سجل المراجعة', 'route' => 'audit.list', 'icon' => 'eye', 'permission' => 'audit.list'], ['label' => 'سجل المراجعة', 'route' => 'audit.list', 'icon' => 'eye', 'permission' => 'audit.list'],
['label' => 'إعدادات الأكاديمية', 'route' => 'settings.academy', 'icon' => 'cog-6-tooth', 'permission' => 'settings.manage'], ['label' => 'إعدادات الأكاديمية', 'route' => 'settings.academy', 'icon' => 'cog-6-tooth', 'permission' => 'settings.manage'],
['label' => 'الهوية البصرية', 'route' => 'settings.branding', 'icon' => 'swatch', 'permission' => 'settings.manage'], ['label' => 'الهوية البصرية', 'route' => 'settings.branding', 'icon' => 'swatch', 'permission' => 'settings.manage'],
...@@ -117,6 +123,7 @@ ...@@ -117,6 +123,7 @@
'chat' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>', 'chat' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>',
'user' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>', 'user' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>',
'receipt-percent' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2zM10 8.5a.5.5 0 11-1 0 .5.5 0 011 0zm5 5a.5.5 0 11-1 0 .5.5 0 011 0z"/>', 'receipt-percent' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2zM10 8.5a.5.5 0 11-1 0 .5.5 0 011 0zm5 5a.5.5 0 11-1 0 .5.5 0 011 0z"/>',
'briefcase' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2"/>',
]; ];
$permissionService = app(\App\Domain\Identity\Services\PermissionService::class); $permissionService = app(\App\Domain\Identity\Services\PermissionService::class);
...@@ -125,6 +132,18 @@ ...@@ -125,6 +132,18 @@
$userCan = function (string $permission) use ($permissionService, $currentUser): bool { $userCan = function (string $permission) use ($permissionService, $currentUser): bool {
return $permissionService->can($currentUser, $permission); return $permissionService->can($currentUser, $permission);
}; };
$userRole = $currentUser->primaryRole?->slug ?? $currentUser->roles->first()?->slug;
$itemVisible = function (array $item) use ($userCan, $userRole): bool {
if (!Route::has($item['route']) || !$userCan($item['permission'])) {
return false;
}
if (isset($item['role']) && $userRole !== $item['role']) {
return false;
}
return true;
};
@endphp @endphp
<aside dir="rtl" <aside dir="rtl"
...@@ -152,7 +171,7 @@ class="fixed top-0 start-0 h-screen w-64 flex flex-col z-40 overflow-hidden tran ...@@ -152,7 +171,7 @@ class="fixed top-0 start-0 h-screen w-64 flex flex-col z-40 overflow-hidden tran
@foreach($navigation as $item) @foreach($navigation as $item)
{{-- Top-level link (no section) --}} {{-- Top-level link (no section) --}}
@if(isset($item['route'])) @if(isset($item['route']))
@if(Route::has($item['route']) && $userCan($item['permission'])) @if($itemVisible($item))
<a href="{{ route($item['route']) }}" <a href="{{ route($item['route']) }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 hover:bg-white/10 active:bg-white/20 active:scale-[0.97]" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 hover:bg-white/10 active:bg-white/20 active:scale-[0.97]"
style="{{ request()->routeIs($item['route'] . '*') ? 'background-color: var(--brand-sidebar-active, #2563eb); color: #fff;' : 'color: var(--brand-sidebar-text, #e2e8f0);' }}"> style="{{ request()->routeIs($item['route'] . '*') ? 'background-color: var(--brand-sidebar-active, #2563eb); color: #fff;' : 'color: var(--brand-sidebar-text, #e2e8f0);' }}">
...@@ -164,8 +183,8 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transi ...@@ -164,8 +183,8 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transi
{{-- Section with items --}} {{-- Section with items --}}
@elseif(isset($item['section'])) @elseif(isset($item['section']))
@php @php
$visibleItems = collect($item['items'])->filter(function ($child) use ($userCan) { $visibleItems = collect($item['items'])->filter(function ($child) use ($itemVisible) {
return Route::has($child['route']) && $userCan($child['permission']); return $itemVisible($child);
}); });
@endphp @endphp
......
This diff is collapsed.
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ __('الموظفين') }}</h1>
@can('employees.create')
<a href="{{ route('employees.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('إضافة موظف') }}
</a>
@endcan
</div>
{{-- Flash messages --}}
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-800 text-sm">{{ session('success') }}</div>
@endif
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<input type="text" wire:model.live.debounce.300ms="search"
placeholder="{{ __('بحث بالاسم أو الرقم الوظيفي...') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<select wire:model.live="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل الحالات') }}</option>
@foreach($statuses as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</select>
<select wire:model.live="employmentType" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل أنواع التوظيف') }}</option>
@foreach($employmentTypes as $t)
<option value="{{ $t->value }}">{{ $t->label() }}</option>
@endforeach
</select>
<select wire:model.live="branchId" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل الفروع') }}</option>
@foreach($branches as $b)
<option value="{{ $b->id }}">{{ $b->name_ar }}</option>
@endforeach
</select>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto" wire:loading.class="opacity-50 pointer-events-none">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الموظف') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الرقم الوظيفي') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('المنصب') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('نوع التوظيف') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الفرع') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('إجراءات') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($employees as $emp)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $emp->person->name_ar ?? '-' }}</td>
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{{ $emp->employee_number }}</td>
<td class="px-4 py-3 text-gray-600">{{ $emp->position ?? '-' }}</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{{ $emp->employment_type->label() }}
</span>
</td>
<td class="px-4 py-3 text-gray-600">{{ $emp->branch?->name_ar ?? '-' }}</td>
<td class="px-4 py-3">
@php
$statusColors = [
'active' => 'bg-green-100 text-green-800',
'on_leave' => 'bg-yellow-100 text-yellow-800',
'suspended' => 'bg-red-100 text-red-800',
'terminated' => 'bg-gray-100 text-gray-800',
'resigned' => 'bg-gray-100 text-gray-600',
];
@endphp
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$emp->status->value] ?? 'bg-gray-100 text-gray-600' }}">
{{ $emp->status->label() }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
@can('employees.update')
<a href="{{ route('employees.edit', $emp) }}" wire:navigate
class="text-blue-600 hover:text-blue-800 text-xs font-medium">{{ __('تعديل') }}</a>
@endcan
@can('employees.delete')
<button wire:click="delete('{{ $emp->uuid }}')"
wire:confirm="{{ __('هل أنت متأكد من حذف هذا الموظف؟') }}"
class="text-red-600 hover:text-red-800 text-xs font-medium">{{ __('حذف') }}</button>
@endcan
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-500">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<p class="font-medium">{{ __('لا يوجد موظفين') }}</p>
<p class="text-sm text-gray-400 mt-1">{{ __('ابدأ بإضافة موظف جديد') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($employees->hasPages())
<div class="px-4 py-3 border-t border-gray-200">
{{ $employees->links() }}
</div>
@endif
</div>
</div>
This diff is collapsed.
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ __('المدربين') }}</h1>
@can('trainers.create')
<a href="{{ route('trainers.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('إضافة مدرب') }}
</a>
@endcan
</div>
{{-- Flash messages --}}
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-800 text-sm">{{ session('success') }}</div>
@endif
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input type="text" wire:model.live.debounce.300ms="search"
placeholder="{{ __('بحث بالاسم أو رقم المدرب...') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<select wire:model.live="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل الحالات') }}</option>
@foreach($statuses as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</select>
<select wire:model.live="compensationModel" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل نماذج التعويض') }}</option>
@foreach($compensationModels as $cm)
<option value="{{ $cm->value }}">{{ $cm->label() }}</option>
@endforeach
</select>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto" wire:loading.class="opacity-50 pointer-events-none">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('المدرب') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('رقم المدرب') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('نموذج التعويض') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('التقييم') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الحصص') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('إجراءات') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($trainers as $trainer)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<p class="font-medium text-gray-900">{{ $trainer->employee->person->name_ar ?? '-' }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ $trainer->employee->branch?->name_ar ?? '' }}</p>
</td>
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{{ $trainer->trainer_number }}</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{{ $trainer->compensation_model->label() }}
</span>
</td>
<td class="px-4 py-3 text-gray-600">
@if($trainer->rating)
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>
{{ number_format($trainer->rating, 1) }}
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-gray-600">{{ $trainer->total_sessions_conducted }}</td>
<td class="px-4 py-3">
@php
$statusColors = [
'active' => 'bg-green-100 text-green-800',
'inactive' => 'bg-gray-100 text-gray-600',
'on_leave' => 'bg-yellow-100 text-yellow-800',
'suspended' => 'bg-red-100 text-red-800',
];
@endphp
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$trainer->status->value] ?? 'bg-gray-100 text-gray-600' }}">
{{ $trainer->status->label() }}
</span>
</td>
<td class="px-4 py-3">
@can('trainers.update')
<a href="{{ route('trainers.edit', $trainer) }}" wire:navigate
class="text-blue-600 hover:text-blue-800 text-xs font-medium">{{ __('تعديل') }}</a>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-500">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342"/></svg>
<p class="font-medium">{{ __('لا يوجد مدربين') }}</p>
<p class="text-sm text-gray-400 mt-1">{{ __('ابدأ بإضافة مدرب جديد') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($trainers->hasPages())
<div class="px-4 py-3 border-t border-gray-200">
{{ $trainers->links() }}
</div>
@endif
</div>
</div>
...@@ -158,11 +158,11 @@ ...@@ -158,11 +158,11 @@
// Activities // Activities
Route::get('/activities', ActivityList::class)->name('activities.list') Route::get('/activities', ActivityList::class)->name('activities.list')
->middleware('permission:participants.list'); ->middleware('permission:activities.list');
Route::get('/activities/create', ActivityForm::class)->name('activities.create') Route::get('/activities/create', ActivityForm::class)->name('activities.create')
->middleware('permission:participants.create'); ->middleware('permission:activities.create');
Route::get('/activities/{activity}/edit', ActivityForm::class)->name('activities.edit') Route::get('/activities/{activity}/edit', ActivityForm::class)->name('activities.edit')
->middleware('permission:participants.update'); ->middleware('permission:activities.update');
// Training Programs // Training Programs
Route::get('/programs', ProgramList::class)->name('programs.list') Route::get('/programs', ProgramList::class)->name('programs.list')
...@@ -240,6 +240,22 @@ ...@@ -240,6 +240,22 @@
Route::get('/attendance/{session}', TakeAttendance::class)->name('attendance.take') Route::get('/attendance/{session}', TakeAttendance::class)->name('attendance.take')
->middleware('permission:attendance.mark'); ->middleware('permission:attendance.mark');
// HR - Employees
Route::get('/hr/employees', \App\Livewire\HR\EmployeeList::class)->name('employees.list')
->middleware('permission:employees.list');
Route::get('/hr/employees/create', \App\Livewire\HR\EmployeeForm::class)->name('employees.create')
->middleware('permission:employees.create');
Route::get('/hr/employees/{employee}/edit', \App\Livewire\HR\EmployeeForm::class)->name('employees.edit')
->middleware('permission:employees.update');
// HR - Trainers
Route::get('/hr/trainers', \App\Livewire\HR\TrainerList::class)->name('trainers.list')
->middleware('permission:trainers.list');
Route::get('/hr/trainers/create', \App\Livewire\HR\TrainerForm::class)->name('trainers.create')
->middleware('permission:trainers.create');
Route::get('/hr/trainers/{trainer}/edit', \App\Livewire\HR\TrainerForm::class)->name('trainers.edit')
->middleware('permission:trainers.update');
// Assignments // Assignments
Route::get('/assignments', AssignmentList::class)->name('assignments.list') Route::get('/assignments', AssignmentList::class)->name('assignments.list')
->middleware('permission:assignments.list'); ->middleware('permission:assignments.list');
...@@ -411,5 +427,6 @@ ...@@ -411,5 +427,6 @@
->middleware('permission:settings.manage'); ->middleware('permission:settings.manage');
// Guardian Portal // Guardian Portal
Route::get('/guardian', GuardianDashboard::class)->name('guardian.dashboard'); Route::get('/guardian', GuardianDashboard::class)->name('guardian.dashboard')
->middleware('permission:dashboard.view');
}); });
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