Commit 0aa21bd4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add full HR payroll/compensation system: auto-earn on attendance, payslips, advances

Complete trainer compensation lifecycle:
- 7 migrations (trainer_compensations, payroll_periods, payslips, payslip_items, trainer_advances, trainer_rate_history + make employee_id nullable for freelancers)
- 7 enums, 6 models with proper relationships
- CompensationCalculatorService: auto-calculates session/player/revenue/penalty earnings
- PayrollService: period management, bulk payslip generation, approval workflow, payment recording
- TrainerAdvanceService: salary advance/loan lifecycle with installment tracking
- Event listeners: AttendanceMarked → auto-compensation, SessionCancelled → trainer pay
- 4 Livewire pages: PayrollDashboard, TrainerCompensations, PayslipDetail, TrainerAdvances
- Sidebar navigation + routes with permission middleware
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f9fa8862
<?php
namespace App\Domain\HR\Enums;
enum AdvanceStatus: string
{
case Active = 'active';
case FullyDeducted = 'fully_deducted';
case Cancelled = 'cancelled';
case Paused = 'paused';
public function label(): string
{
return match($this) {
self::Active => 'نشطة',
self::FullyDeducted => 'مسددة بالكامل',
self::Cancelled => 'ملغاة',
self::Paused => 'متوقفة',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum CompensationStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Disputed = 'disputed';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
<?php
namespace App\Domain\HR\Enums;
enum CompensationType: string
{
case SessionPay = 'session_pay';
case GroupPay = 'group_pay';
case PlayerPay = 'player_pay';
case RevenueShare = 'revenue_share';
case Bonus = 'bonus';
case Penalty = 'penalty';
case Overtime = 'overtime';
case Substitute = 'substitute';
case CancelledSession = 'cancelled_session';
public function label(): string
{
return match($this) {
self::SessionPay => 'أجر حصة',
self::GroupPay => 'أجر مجموعة',
self::PlayerPay => 'أجر لاعب',
self::RevenueShare => 'حصة إيرادات',
self::Bonus => 'مكافأة',
self::Penalty => 'خصم',
self::Overtime => 'عمل إضافي',
self::Substitute => 'حصة بديلة',
self::CancelledSession => 'حصة ملغاة',
};
}
public function isEarning(): bool
{
return $this !== self::Penalty;
}
}
<?php
namespace App\Domain\HR\Enums;
enum PayrollPeriodStatus: string
{
case Open = 'open';
case Calculating = 'calculating';
case Review = 'review';
case Approved = 'approved';
case Closed = 'closed';
}
<?php
namespace App\Domain\HR\Enums;
enum PayslipItemType: string
{
case BaseSalary = 'base_salary';
case SessionPay = 'session_pay';
case GroupPay = 'group_pay';
case PlayerPay = 'player_pay';
case RevenueShare = 'revenue_share';
case Overtime = 'overtime';
case Substitute = 'substitute';
case Bonus = 'bonus';
case Penalty = 'penalty';
case AdvanceDeduction = 'advance_deduction';
case Tax = 'tax';
case Insurance = 'insurance';
case OtherDeduction = 'other_deduction';
case CancelledSessionPay = 'cancelled_session_pay';
public function isDeduction(): bool
{
return in_array($this, [
self::Penalty,
self::AdvanceDeduction,
self::Tax,
self::Insurance,
self::OtherDeduction,
]);
}
public function label(): string
{
return match($this) {
self::BaseSalary => 'الراتب الأساسي',
self::SessionPay => 'أجر الحصص',
self::GroupPay => 'أجر المجموعات',
self::PlayerPay => 'أجر اللاعبين',
self::RevenueShare => 'حصة الإيرادات',
self::Overtime => 'العمل الإضافي',
self::Substitute => 'حصص بديلة',
self::Bonus => 'مكافآت',
self::Penalty => 'خصومات',
self::AdvanceDeduction => 'قسط سلفة',
self::Tax => 'ضرائب',
self::Insurance => 'تأمينات',
self::OtherDeduction => 'خصومات أخرى',
self::CancelledSessionPay => 'حصص ملغاة',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum PayslipStatus: string
{
case Draft = 'draft';
case PendingApproval = 'pending_approval';
case Approved = 'approved';
case Paid = 'paid';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Draft => 'مسودة',
self::PendingApproval => 'بانتظار الموافقة',
self::Approved => 'موافق عليها',
self::Paid => 'مدفوعة',
self::Cancelled => 'ملغاة',
};
}
public function badgeColor(): string
{
return match($this) {
self::Draft => 'gray',
self::PendingApproval => 'amber',
self::Approved => 'blue',
self::Paid => 'green',
self::Cancelled => 'red',
};
}
}
<?php
namespace App\Domain\HR\Enums;
enum StaffPaymentMethod: string
{
case Cash = 'cash';
case BankTransfer = 'bank_transfer';
case Instapay = 'instapay';
case Cheque = 'cheque';
public function label(): string
{
return match($this) {
self::Cash => 'نقدي',
self::BankTransfer => 'تحويل بنكي',
self::Instapay => 'إنستاباي',
self::Cheque => 'شيك',
};
}
}
<?php
namespace App\Domain\HR\Listeners;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\CompensationCalculatorService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class GenerateTrainerCompensation implements ShouldQueue
{
public function __construct(
private CompensationCalculatorService $calculator,
) {}
public function handle(AttendanceMarked $event): void
{
try {
$record = $event->record;
// Only process trainer/staff attendance, not participant
if ($record->subject_type !== 'trainer' && $record->subject_type !== 'staff') {
return;
}
// Find the trainer by subject_id
$trainer = Trainer::find($record->subject_id);
if (!$trainer || $trainer->status->value !== 'active') {
return;
}
$actor = $event->actor;
$status = $record->status->value;
match ($status) {
'present' => $this->calculator->calculateForSession(
$trainer,
$record->training_session_id,
$record->id,
'present',
$actor,
),
'late' => (function () use ($trainer, $record, $actor) {
$this->calculator->calculateForSession($trainer, $record->training_session_id, $record->id, 'late', $actor);
$this->calculator->calculateLatePenalty($trainer, $record->training_session_id, $record->id, $actor);
})(),
'absent', 'no_show' => $this->calculator->calculatePenalty(
$trainer,
$record->training_session_id,
$record->id,
$actor,
),
default => null,
};
} catch (\Throwable $e) {
Log::error('GenerateTrainerCompensation failed: ' . $e->getMessage(), [
'attendance_record_id' => $event->record->id ?? null,
]);
}
}
public function failed(AttendanceMarked $event, \Throwable $exception): void
{
Log::critical('GenerateTrainerCompensation PERMANENTLY FAILED', [
'attendance_record_id' => $event->record->id ?? null,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\HR\Listeners;
use App\Domain\Training\Events\SessionCancelled;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\CompensationCalculatorService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class HandleSessionCancelled implements ShouldQueue
{
public function __construct(
private CompensationCalculatorService $calculator,
) {}
public function handle(SessionCancelled $event): void
{
try {
$session = $event->session;
// Find user_ids assigned to this session or its group via polymorphic assignments
$userIds = \App\Domain\Scheduling\Models\Assignment::where(function ($q) use ($session) {
$q->where(function ($q2) use ($session) {
$q2->where('assignable_type', \App\Domain\Training\Models\TrainingSession::class)
->where('assignable_id', $session->id);
})->orWhere(function ($q2) use ($session) {
$q2->where('assignable_type', \App\Domain\Training\Models\TrainingGroup::class)
->where('assignable_id', $session->training_group_id);
});
})
->where('status', 'active')
->pluck('user_id')
->unique();
// Resolve trainers from user_ids via their employee records
$trainers = Trainer::whereHas('employee', function ($q) use ($userIds) {
$q->whereIn('user_id', $userIds);
})->where('status', 'active')->get();
foreach ($trainers as $trainer) {
$this->calculator->calculateCancelledSessionPay(
$trainer,
$session->id,
$event->actor,
);
}
} catch (\Throwable $e) {
Log::error('HandleSessionCancelled compensation failed: ' . $e->getMessage(), [
'session_id' => $event->session->id ?? null,
]);
}
}
public function failed(SessionCancelled $event, \Throwable $exception): void
{
Log::critical('HandleSessionCancelled PERMANENTLY FAILED', [
'session_id' => $event->session->id ?? null,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\PayrollPeriodStatus;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PayrollPeriod extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'period_start',
'period_end',
'status',
'total_gross',
'total_deductions',
'total_net',
'payslip_count',
'approved_by',
'approved_at',
'closed_at',
'notes',
'created_by',
];
protected $casts = [
'status' => PayrollPeriodStatus::class,
'period_start' => 'date',
'period_end' => 'date',
'total_gross' => 'integer',
'total_deductions' => 'integer',
'total_net' => 'integer',
'payslip_count' => 'integer',
'approved_at' => 'datetime',
'closed_at' => 'datetime',
];
// --- Relationships ---
public function payslips(): HasMany
{
return $this->hasMany(Payslip::class);
}
public function approver(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'approved_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\PayslipStatus;
use App\Domain\HR\Enums\StaffPaymentMethod;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Payslip extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'trainer_id',
'payroll_period_id',
'payslip_number',
'base_amount',
'session_earnings',
'bonuses',
'penalties',
'advances_deducted',
'tax_amount',
'insurance_amount',
'other_deductions',
'gross_amount',
'total_deductions',
'net_amount',
'status',
'approved_by',
'approved_at',
'paid_at',
'payment_method',
'payment_reference',
'notes',
'metadata',
'created_by',
];
protected $casts = [
'status' => PayslipStatus::class,
'payment_method' => StaffPaymentMethod::class,
'base_amount' => 'integer',
'session_earnings' => 'integer',
'bonuses' => 'integer',
'penalties' => 'integer',
'advances_deducted' => 'integer',
'tax_amount' => 'integer',
'insurance_amount' => 'integer',
'other_deductions' => 'integer',
'gross_amount' => 'integer',
'total_deductions' => 'integer',
'net_amount' => 'integer',
'approved_at' => 'datetime',
'paid_at' => 'datetime',
'metadata' => 'array',
];
// --- Relationships ---
public function trainer(): BelongsTo
{
return $this->belongsTo(Trainer::class);
}
public function period(): BelongsTo
{
return $this->belongsTo(PayrollPeriod::class, 'payroll_period_id');
}
public function items(): HasMany
{
return $this->hasMany(PayslipItem::class);
}
public function approver(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'approved_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\PayslipItemType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class PayslipItem extends Model
{
protected $fillable = [
'payslip_id',
'type',
'description',
'quantity',
'rate',
'amount',
'is_deduction',
'source_type',
'source_id',
'metadata',
'sort_order',
];
protected $casts = [
'type' => PayslipItemType::class,
'quantity' => 'decimal:2',
'rate' => 'integer',
'amount' => 'integer',
'is_deduction' => 'boolean',
'metadata' => 'array',
'sort_order' => 'integer',
];
// --- Relationships ---
public function payslip(): BelongsTo
{
return $this->belongsTo(Payslip::class);
}
public function source(): MorphTo
{
return $this->morphTo('source', 'source_type', 'source_id');
}
}
......@@ -11,6 +11,10 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use App\Domain\HR\Models\TrainerCompensation;
use App\Domain\HR\Models\Payslip;
use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\HR\Models\TrainerRateHistory;
class Trainer extends Model
{
......@@ -19,6 +23,7 @@ class Trainer extends Model
protected $fillable = [
'academy_id',
'employee_id',
'person_id',
'trainer_number',
'bio',
'bio_ar',
......@@ -60,6 +65,31 @@ public function employee(): BelongsTo
return $this->belongsTo(Employee::class);
}
public function person(): BelongsTo
{
return $this->belongsTo(\App\Domain\People\Models\Person::class);
}
public function compensations(): HasMany
{
return $this->hasMany(TrainerCompensation::class);
}
public function payslips(): HasMany
{
return $this->hasMany(Payslip::class);
}
public function advances(): HasMany
{
return $this->hasMany(TrainerAdvance::class);
}
public function rateHistory(): HasMany
{
return $this->hasMany(TrainerRateHistory::class);
}
public function qualifications(): HasMany
{
return $this->hasMany(TrainerQualification::class);
......
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\AdvanceStatus;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class TrainerAdvance extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'trainer_id',
'amount',
'remaining_balance',
'installment_amount',
'installments_count',
'installments_paid',
'reason',
'status',
'issued_date',
'expected_completion_date',
'approved_by',
'notes',
'metadata',
'created_by',
];
protected $casts = [
'status' => AdvanceStatus::class,
'amount' => 'integer',
'remaining_balance' => 'integer',
'installment_amount' => 'integer',
'installments_count' => 'integer',
'installments_paid' => 'integer',
'issued_date' => 'date',
'expected_completion_date' => 'date',
'metadata' => 'array',
];
// --- Relationships ---
public function trainer(): BelongsTo
{
return $this->belongsTo(Trainer::class);
}
public function approver(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'approved_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\HR\Enums\CompensationStatus;
use App\Domain\HR\Enums\CompensationType;
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\SoftDeletes;
class TrainerCompensation extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'trainer_id',
'training_session_id',
'attendance_record_id',
'payslip_item_id',
'date',
'type',
'description',
'quantity',
'rate',
'amount',
'status',
'approved_by',
'approved_at',
'notes',
'metadata',
'created_by',
];
protected $casts = [
'type' => CompensationType::class,
'status' => CompensationStatus::class,
'date' => 'date',
'quantity' => 'decimal:2',
'rate' => 'integer',
'amount' => 'integer',
'approved_at' => 'datetime',
'metadata' => 'array',
];
// --- Relationships ---
public function trainer(): BelongsTo
{
return $this->belongsTo(Trainer::class);
}
public function session(): BelongsTo
{
return $this->belongsTo(\App\Domain\Training\Models\TrainingSession::class, 'training_session_id');
}
public function attendanceRecord(): BelongsTo
{
return $this->belongsTo(\App\Domain\Attendance\Models\AttendanceRecord::class, 'attendance_record_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'approved_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
// --- Scopes ---
public function scopePending(Builder $query): Builder
{
return $query->where('status', CompensationStatus::Pending->value);
}
public function scopeApproved(Builder $query): Builder
{
return $query->where('status', CompensationStatus::Approved->value);
}
public function scopeForPeriod(Builder $query, string $start, string $end): Builder
{
return $query->whereBetween('date', [$start, $end]);
}
}
<?php
namespace App\Domain\HR\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TrainerRateHistory extends Model
{
use BelongsToAcademy;
protected $fillable = [
'academy_id',
'trainer_id',
'field',
'old_value',
'new_value',
'effective_from',
'changed_by',
'notes',
];
protected $casts = [
'effective_from' => 'date',
];
// --- Relationships ---
public function trainer(): BelongsTo
{
return $this->belongsTo(Trainer::class);
}
public function changedBy(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'changed_by');
}
}
This diff is collapsed.
This diff is collapsed.
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Enums\AdvanceStatus;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class TrainerAdvanceService
{
/**
* Create a new advance for a trainer.
*
* Business rules enforced:
* - A trainer can only have one active advance at a time.
* - Amount must be positive.
* - At least one installment must be specified.
* - The per-installment amount is rounded UP (ceil) so the trainer always
* pays back at least the full principal; any over-deduction in the last
* installment is capped by the remaining balance inside PayrollService.
*/
public function create(
Trainer $trainer,
int $amount,
int $installmentsCount,
?string $reason,
User $actor,
): TrainerAdvance {
// Guard: no concurrent active advance
$existing = TrainerAdvance::where('trainer_id', $trainer->id)
->where('status', AdvanceStatus::Active->value)
->exists();
if ($existing) {
throw new DomainException('يوجد سلفة نشطة بالفعل — يجب تسديدها أو إلغائها أولاً');
}
if ($amount <= 0) {
throw new DomainException('مبلغ السلفة يجب أن يكون أكبر من صفر');
}
if ($installmentsCount < 1) {
throw new DomainException('عدد الأقساط يجب أن يكون قسط واحد على الأقل');
}
// Ceiling division so the sum of installments never falls short of principal
$installmentAmount = (int) ceil($amount / $installmentsCount);
return DB::transaction(function () use ($trainer, $amount, $installmentAmount, $installmentsCount, $reason, $actor) {
return TrainerAdvance::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'amount' => $amount,
'remaining_balance' => $amount,
'installment_amount' => $installmentAmount,
'installments_count' => $installmentsCount,
'installments_paid' => 0,
'reason' => $reason,
'status' => AdvanceStatus::Active,
'issued_date' => now()->toDateString(),
'expected_completion_date' => now()->addMonths($installmentsCount)->toDateString(),
'approved_by' => $actor->id,
'created_by' => $actor->id,
]);
});
}
/**
* Cancel an advance (active or paused advances may be cancelled).
*/
public function cancel(TrainerAdvance $advance, User $actor): TrainerAdvance
{
if (
$advance->status !== AdvanceStatus::Active &&
$advance->status !== AdvanceStatus::Paused
) {
throw new DomainException('لا يمكن إلغاء هذه السلفة في حالتها الحالية');
}
return DB::transaction(function () use ($advance) {
$advance->update(['status' => AdvanceStatus::Cancelled]);
return $advance->fresh();
});
}
/**
* Pause an advance — stops deductions until explicitly resumed.
*/
public function pause(TrainerAdvance $advance): TrainerAdvance
{
if ($advance->status !== AdvanceStatus::Active) {
throw new DomainException('لا يمكن إيقاف سلفة غير نشطة');
}
return DB::transaction(function () use ($advance) {
$advance->update(['status' => AdvanceStatus::Paused]);
return $advance->fresh();
});
}
/**
* Resume a paused advance — deductions restart on the next payroll cycle.
*/
public function resume(TrainerAdvance $advance): TrainerAdvance
{
if ($advance->status !== AdvanceStatus::Paused) {
throw new DomainException('هذه السلفة ليست متوقفة');
}
return DB::transaction(function () use ($advance) {
$advance->update(['status' => AdvanceStatus::Active]);
return $advance->fresh();
});
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\PayrollPeriodStatus;
use App\Domain\HR\Enums\PayslipStatus;
use App\Domain\HR\Models\PayrollPeriod;
use App\Domain\HR\Models\Payslip;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\PayrollService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('إدارة الرواتب')]
class PayrollDashboard extends Component
{
use WithPagination;
#[Url]
public string $periodFilter = '';
#[Url]
public string $payslipStatus = '';
#[Url]
public string $search = '';
#[Url]
public string $activeTab = 'periods'; // periods | payslips
// Stats
public int $totalPayrollThisMonth = 0;
public int $pendingApprovals = 0;
public int $paidThisMonth = 0;
public int $activeTrainers = 0;
// Mark-paid modal state
public bool $showMarkPaidModal = false;
public ?int $markPaidPayslipId = null;
public string $markPaidMethod = 'cash';
public string $markPaidReference = '';
public function mount(): void
{
$this->authorize('payroll.manage');
$this->loadStats();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPeriodFilter(): void
{
$this->resetPage();
}
public function updatedPayslipStatus(): void
{
$this->resetPage();
}
public function loadStats(): void
{
$academyId = app('current_academy')->id;
$currentPeriod = PayrollPeriod::where('academy_id', $academyId)
->latest('period_start')
->first();
$this->totalPayrollThisMonth = $currentPeriod?->total_net ?? 0;
$this->pendingApprovals = Payslip::where('academy_id', $academyId)
->whereIn('status', [
PayslipStatus::Draft->value,
PayslipStatus::PendingApproval->value,
])
->count();
$this->paidThisMonth = Payslip::where('academy_id', $academyId)
->where('status', PayslipStatus::Paid->value)
->whereMonth('paid_at', now()->month)
->whereYear('paid_at', now()->year)
->sum('net_amount');
$this->activeTrainers = Trainer::where('academy_id', $academyId)
->where('status', 'active')
->count();
}
public function createPeriod(): void
{
try {
$service = app(PayrollService::class);
$service->getOrCreateCurrentPeriod(
app('current_academy')->id,
auth()->user()
);
session()->flash('success', __('تم إنشاء فترة الرواتب بنجاح'));
$this->loadStats();
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function calculatePeriod(int $periodId): void
{
try {
$period = PayrollPeriod::findOrFail($periodId);
$service = app(PayrollService::class);
$service->calculatePeriod($period, auth()->user());
session()->flash('success', __('تم احتساب كشوف الرواتب بنجاح'));
$this->loadStats();
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function approvePeriod(int $periodId): void
{
try {
$period = PayrollPeriod::findOrFail($periodId);
$service = app(PayrollService::class);
$service->approvePeriod($period, auth()->user());
session()->flash('success', __('تم اعتماد فترة الرواتب'));
$this->loadStats();
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function closePeriod(int $periodId): void
{
try {
$period = PayrollPeriod::findOrFail($periodId);
$service = app(PayrollService::class);
$service->closePeriod($period, auth()->user());
session()->flash('success', __('تم إغلاق فترة الرواتب'));
$this->loadStats();
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function approvePayslip(int $payslipId): void
{
try {
$payslip = Payslip::findOrFail($payslipId);
$service = app(PayrollService::class);
$service->approvePayslip($payslip, auth()->user());
session()->flash('success', __('تم اعتماد كشف الراتب'));
$this->loadStats();
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function openMarkPaidModal(int $payslipId): void
{
$this->markPaidPayslipId = $payslipId;
$this->markPaidMethod = 'cash';
$this->markPaidReference = '';
$this->showMarkPaidModal = true;
}
public function confirmMarkPaid(): void
{
if (!$this->markPaidPayslipId) {
return;
}
try {
$payslip = Payslip::findOrFail($this->markPaidPayslipId);
$service = app(PayrollService::class);
$service->markPaid(
$payslip,
$this->markPaidMethod,
$this->markPaidReference ?: null,
auth()->user()
);
$this->showMarkPaidModal = false;
$this->markPaidPayslipId = null;
session()->flash('success', __('تم تسجيل الدفع بنجاح'));
$this->loadStats();
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function closeModal(): void
{
$this->showMarkPaidModal = false;
$this->markPaidPayslipId = null;
}
public function render()
{
$periodsQuery = PayrollPeriod::where('academy_id', app('current_academy')->id)
->latest('period_start');
if ($this->periodFilter) {
$periodsQuery->where('status', $this->periodFilter);
}
$payslipsQuery = Payslip::with(['trainer.employee.person', 'period'])
->where('academy_id', app('current_academy')->id)
->latest();
if ($this->payslipStatus) {
$payslipsQuery->where('status', $this->payslipStatus);
}
if ($this->search) {
$search = $this->search;
$payslipsQuery->whereHas('trainer.employee.person', function ($q) use ($search) {
$q->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
});
}
return view('livewire.hr.payroll-dashboard', [
'periods' => $periodsQuery->paginate(10, pageName: 'periodsPage'),
'payslips' => $payslipsQuery->paginate(15, pageName: 'payslipsPage'),
'periodStatuses' => PayrollPeriodStatus::cases(),
'payslipStatuses' => PayslipStatus::cases(),
'paymentMethods' => \App\Domain\HR\Enums\StaffPaymentMethod::cases(),
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\PayslipStatus;
use App\Domain\HR\Enums\StaffPaymentMethod;
use App\Domain\HR\Models\Payslip;
use App\Domain\HR\Services\PayrollService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('تفاصيل كشف الراتب')]
class PayslipDetail extends Component
{
public Payslip $payslip;
public string $paymentMethod = 'cash';
public string $paymentReference = '';
public bool $showPaymentModal = false;
public function mount(Payslip $payslip): void
{
$this->authorize('payroll.manage');
$this->payslip = $payslip->load([
'trainer.employee.person',
'trainer.person',
'items',
'period',
'approver',
]);
}
public function approve(): void
{
try {
$service = app(PayrollService::class);
$service->approvePayslip($this->payslip, auth()->user());
$this->payslip = $this->payslip->fresh([
'trainer.employee.person',
'trainer.person',
'items',
'period',
'approver',
]);
session()->flash('success', __('تم اعتماد كشف الراتب'));
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function openPaymentModal(): void
{
$this->showPaymentModal = true;
}
public function processPayment(): void
{
$this->validate([
'paymentMethod' => 'required|in:' . implode(',', array_column(StaffPaymentMethod::cases(), 'value')),
'paymentReference' => 'nullable|string|max:255',
]);
try {
$service = app(PayrollService::class);
$service->markPaid(
$this->payslip,
$this->paymentMethod,
$this->paymentReference ?: null,
auth()->user()
);
$this->payslip = $this->payslip->fresh([
'trainer.employee.person',
'trainer.person',
'items',
'period',
'approver',
]);
$this->showPaymentModal = false;
$this->paymentReference = '';
session()->flash('success', __('تم تسجيل الدفع بنجاح'));
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$earningItems = $this->payslip->items->where('is_deduction', false)->sortBy('sort_order');
$deductionItems = $this->payslip->items->where('is_deduction', true)->sortBy('sort_order');
return view('livewire.hr.payslip-detail', [
'earningItems' => $earningItems,
'deductionItems' => $deductionItems,
'paymentMethods' => StaffPaymentMethod::cases(),
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\AdvanceStatus;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\HR\Services\TrainerAdvanceService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('السلف')]
class TrainerAdvances extends Component
{
use WithPagination;
#[Url]
public string $statusFilter = '';
#[Url]
public string $search = '';
// Create form
public bool $showCreateModal = false;
public ?int $selectedTrainerId = null;
public string $amount = '';
public int $installmentsCount = 3;
public string $reason = '';
public function mount(): void
{
$this->authorize('payroll.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function openCreateModal(): void
{
$this->reset(['selectedTrainerId', 'amount', 'installmentsCount', 'reason']);
$this->installmentsCount = 3;
$this->showCreateModal = true;
}
public function createAdvance(): void
{
$this->validate([
'selectedTrainerId' => 'required|exists:trainers,id',
'amount' => 'required|numeric|min:1',
'installmentsCount' => 'required|integer|min:1|max:24',
], [
'selectedTrainerId.required' => 'يجب اختيار مدرب',
'amount.required' => 'يجب إدخال المبلغ',
'amount.min' => 'المبلغ يجب أن يكون أكبر من صفر',
'installmentsCount.required' => 'يجب تحديد عدد الأقساط',
'installmentsCount.min' => 'قسط واحد على الأقل',
]);
try {
$trainer = Trainer::findOrFail($this->selectedTrainerId);
$amountPiasters = (int) round((float) $this->amount * 100);
$service = app(TrainerAdvanceService::class);
$service->create(
$trainer,
$amountPiasters,
$this->installmentsCount,
$this->reason ?: null,
auth()->user()
);
$this->showCreateModal = false;
session()->flash('success', __('تم تسجيل السلفة بنجاح'));
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function cancelAdvance(int $id): void
{
try {
$advance = TrainerAdvance::findOrFail($id);
$service = app(TrainerAdvanceService::class);
$service->cancel($advance, auth()->user());
session()->flash('success', __('تم إلغاء السلفة'));
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function pauseAdvance(int $id): void
{
try {
$advance = TrainerAdvance::findOrFail($id);
$service = app(TrainerAdvanceService::class);
$service->pause($advance);
session()->flash('success', __('تم إيقاف السلفة'));
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function resumeAdvance(int $id): void
{
try {
$advance = TrainerAdvance::findOrFail($id);
$service = app(TrainerAdvanceService::class);
$service->resume($advance);
session()->flash('success', __('تم استئناف السلفة'));
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$query = TrainerAdvance::with(['trainer.employee.person', 'trainer.person'])
->latest('issued_date');
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
if ($this->search) {
$search = $this->search;
$query->whereHas('trainer.employee.person', function ($q) use ($search) {
$q->where('name_ar', 'ilike', "%{$search}%");
})->orWhereHas('trainer.person', function ($q) use ($search) {
$q->where('name_ar', 'ilike', "%{$search}%");
});
}
$trainers = Trainer::where('status', 'active')
->with('employee.person', 'person')
->get();
return view('livewire.hr.trainer-advances', [
'advances' => $query->paginate(15),
'statuses' => AdvanceStatus::cases(),
'trainers' => $trainers,
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\CompensationStatus;
use App\Domain\HR\Enums\CompensationType;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerCompensation;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('تعويضات المدرب')]
class TrainerCompensations extends Component
{
use WithPagination;
public Trainer $trainer;
#[Url]
public string $typeFilter = '';
#[Url]
public string $statusFilter = '';
#[Url]
public string $dateFrom = '';
#[Url]
public string $dateTo = '';
// Summary stats
public int $totalEarnings = 0;
public int $totalPenalties = 0;
public int $netAmount = 0;
public int $sessionsCount = 0;
public function mount(Trainer $trainer): void
{
$this->authorize('payroll.manage');
$this->trainer = $trainer;
$this->dateFrom = now()->startOfMonth()->format('Y-m-d');
$this->dateTo = now()->format('Y-m-d');
$this->loadSummary();
}
public function updatedDateFrom(): void
{
$this->resetPage();
$this->loadSummary();
}
public function updatedDateTo(): void
{
$this->resetPage();
$this->loadSummary();
}
public function updatedTypeFilter(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function loadSummary(): void
{
$baseQuery = TrainerCompensation::where('trainer_id', $this->trainer->id)
->whereNotIn('status', [CompensationStatus::Cancelled->value]);
if ($this->dateFrom) {
$baseQuery->where('date', '>=', $this->dateFrom);
}
if ($this->dateTo) {
$baseQuery->where('date', '<=', $this->dateTo);
}
$all = $baseQuery->get();
$this->totalEarnings = $all->filter(fn ($c) => $c->type->isEarning())->sum('amount');
$this->totalPenalties = $all->filter(fn ($c) => ! $c->type->isEarning())->sum('amount');
$this->netAmount = $this->totalEarnings - $this->totalPenalties;
$this->sessionsCount = $all->whereIn('type', [CompensationType::SessionPay, CompensationType::Substitute])->count();
}
public function approveRecord(int $id): void
{
$record = TrainerCompensation::findOrFail($id);
if ($record->status === CompensationStatus::Pending) {
$record->update([
'status' => CompensationStatus::Approved,
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
session()->flash('success', __('تم اعتماد السجل'));
$this->loadSummary();
}
}
public function disputeRecord(int $id): void
{
$record = TrainerCompensation::findOrFail($id);
if (in_array($record->status, [CompensationStatus::Pending, CompensationStatus::Approved])) {
$record->update(['status' => CompensationStatus::Disputed]);
session()->flash('success', __('تم تسجيل الاعتراض'));
$this->loadSummary();
}
}
public function render()
{
$query = TrainerCompensation::where('trainer_id', $this->trainer->id)
->with('session')
->latest('date');
if ($this->typeFilter) {
$query->where('type', $this->typeFilter);
}
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
if ($this->dateFrom) {
$query->where('date', '>=', $this->dateFrom);
}
if ($this->dateTo) {
$query->where('date', '<=', $this->dateTo);
}
return view('livewire.hr.trainer-compensations', [
'compensations' => $query->paginate(20),
'types' => CompensationType::cases(),
'statuses' => CompensationStatus::cases(),
]);
}
}
......@@ -63,6 +63,7 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Training\Events\SessionCancelled::class => [
\App\Domain\Training\Listeners\NotifySessionCancellation::class,
\App\Domain\Training\Listeners\CancelLinkedReservation::class,
\App\Domain\HR\Listeners\HandleSessionCancelled::class,
],
\App\Domain\Training\Events\SessionCompleted::class => [
\App\Domain\Training\Listeners\SendSessionCompletedNotification::class,
......@@ -81,7 +82,9 @@ class EventServiceProvider extends ServiceProvider
],
// Attendance Events
\App\Domain\Attendance\Events\AttendanceMarked::class => [],
\App\Domain\Attendance\Events\AttendanceMarked::class => [
\App\Domain\HR\Listeners\GenerateTrainerCompensation::class,
],
\App\Domain\Attendance\Events\ParticipantAbsent::class => [
\App\Domain\Attendance\Listeners\NotifyGuardianOfAbsence::class,
],
......
<?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::table('trainers', function (Blueprint $table) {
// Drop the existing unique constraint and FK on employee_id
$table->dropUnique(['employee_id']);
$table->dropForeign(['employee_id']);
// Make employee_id nullable and re-add FK with nullOnDelete
$table->foreignId('employee_id')->nullable()->change();
$table->foreign('employee_id')->references('id')->on('employees')->nullOnDelete();
$table->unique(['employee_id']); // still unique when set
// Add person_id as alternative link for freelancers
$table->foreignId('person_id')->nullable()->after('employee_id')->constrained('people');
});
// Must have at least one of employee_id or person_id
DB::statement('ALTER TABLE trainers ADD CONSTRAINT trainers_person_or_employee_check CHECK (employee_id IS NOT NULL OR person_id IS NOT NULL)');
}
public function down(): void
{
DB::statement('ALTER TABLE trainers DROP CONSTRAINT IF EXISTS trainers_person_or_employee_check');
Schema::table('trainers', function (Blueprint $table) {
$table->dropForeign(['person_id']);
$table->dropColumn('person_id');
$table->dropUnique(['employee_id']);
$table->dropForeign(['employee_id']);
$table->foreignId('employee_id')->nullable(false)->change();
$table->foreign('employee_id')->references('id')->on('employees');
$table->unique(['employee_id']);
});
}
};
<?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_compensations', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('trainer_id')->constrained('trainers');
$table->foreignId('training_session_id')->nullable()->constrained('training_sessions');
$table->foreignId('attendance_record_id')->nullable()->constrained('attendance_records');
$table->unsignedBigInteger('payslip_item_id')->nullable(); // filled when included in a payslip item
$table->date('date')->index();
$table->string('type', 20);
$table->string('description', 255)->nullable();
$table->decimal('quantity', 8, 2)->default(1);
$table->bigInteger('rate'); // piasters per unit
$table->bigInteger('amount'); // total piasters (quantity × rate, or manual)
$table->string('status', 20)->default('pending');
$table->foreignId('approved_by')->nullable()->constrained('users');
$table->dateTime('approved_at')->nullable();
$table->text('notes')->nullable();
$table->jsonb('metadata')->default('{}');
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->index(['academy_id', 'trainer_id', 'date']);
$table->index(['academy_id', 'status']);
$table->index(['training_session_id']);
});
DB::statement("ALTER TABLE trainer_compensations ADD CONSTRAINT trainer_compensations_type_check CHECK (type IN ('session_pay', 'group_pay', 'player_pay', 'revenue_share', 'bonus', 'penalty', 'overtime', 'substitute', 'cancelled_session'))");
DB::statement("ALTER TABLE trainer_compensations ADD CONSTRAINT trainer_compensations_status_check CHECK (status IN ('pending', 'approved', 'disputed', 'paid', 'cancelled'))");
}
public function down(): void
{
Schema::dropIfExists('trainer_compensations');
}
};
<?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('payroll_periods', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->date('period_start');
$table->date('period_end');
$table->string('status', 20)->default('open');
$table->bigInteger('total_gross')->default(0);
$table->bigInteger('total_deductions')->default(0);
$table->bigInteger('total_net')->default(0);
$table->integer('payslip_count')->default(0);
$table->foreignId('approved_by')->nullable()->constrained('users');
$table->dateTime('approved_at')->nullable();
$table->dateTime('closed_at')->nullable();
$table->text('notes')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->index(['academy_id', 'status']);
$table->unique(['academy_id', 'period_start', 'period_end']);
});
DB::statement("ALTER TABLE payroll_periods ADD CONSTRAINT payroll_periods_status_check CHECK (status IN ('open', 'calculating', 'review', 'approved', 'closed'))");
}
public function down(): void
{
Schema::dropIfExists('payroll_periods');
}
};
<?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('payslips', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('trainer_id')->constrained('trainers');
$table->foreignId('payroll_period_id')->constrained('payroll_periods');
$table->string('payslip_number', 30);
$table->bigInteger('base_amount')->default(0);
$table->bigInteger('session_earnings')->default(0);
$table->bigInteger('bonuses')->default(0);
$table->bigInteger('penalties')->default(0);
$table->bigInteger('advances_deducted')->default(0);
$table->bigInteger('tax_amount')->default(0);
$table->bigInteger('insurance_amount')->default(0);
$table->bigInteger('other_deductions')->default(0);
$table->bigInteger('gross_amount')->default(0);
$table->bigInteger('total_deductions')->default(0);
$table->bigInteger('net_amount')->default(0);
$table->string('status', 20)->default('draft');
$table->foreignId('approved_by')->nullable()->constrained('users');
$table->dateTime('approved_at')->nullable();
$table->dateTime('paid_at')->nullable();
$table->string('payment_method', 20)->nullable();
$table->string('payment_reference', 100)->nullable();
$table->text('notes')->nullable();
$table->jsonb('metadata')->default('{}');
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->index(['academy_id', 'trainer_id']);
$table->index(['academy_id', 'payroll_period_id']);
$table->index(['academy_id', 'status']);
$table->unique(['academy_id', 'payslip_number']);
});
DB::statement("ALTER TABLE payslips ADD CONSTRAINT payslips_status_check CHECK (status IN ('draft', 'pending_approval', 'approved', 'paid', 'cancelled'))");
DB::statement("ALTER TABLE payslips ADD CONSTRAINT payslips_payment_method_check CHECK (payment_method IS NULL OR payment_method IN ('cash', 'bank_transfer', 'instapay', 'cheque'))");
}
public function down(): void
{
Schema::dropIfExists('payslips');
}
};
<?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('payslip_items', function (Blueprint $table) {
$table->id();
$table->foreignId('payslip_id')->constrained('payslips');
$table->string('type', 30);
$table->string('description', 255);
$table->decimal('quantity', 8, 2)->default(1);
$table->bigInteger('rate')->default(0);
$table->bigInteger('amount'); // can be negative for deductions
$table->boolean('is_deduction')->default(false);
$table->string('source_type', 100)->nullable(); // polymorphic: TrainerCompensation, TrainerAdvance, etc.
$table->unsignedBigInteger('source_id')->nullable();
$table->jsonb('metadata')->default('{}');
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['payslip_id', 'type']);
});
DB::statement("ALTER TABLE payslip_items ADD CONSTRAINT payslip_items_type_check CHECK (type IN ('base_salary', 'session_pay', 'group_pay', 'player_pay', 'revenue_share', 'overtime', 'substitute', 'bonus', 'penalty', 'advance_deduction', 'tax', 'insurance', 'other_deduction', 'cancelled_session_pay'))");
}
public function down(): void
{
Schema::dropIfExists('payslip_items');
}
};
<?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_advances', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('trainer_id')->constrained('trainers');
$table->bigInteger('amount'); // total advance amount (piasters)
$table->bigInteger('remaining_balance'); // how much is still owed
$table->bigInteger('installment_amount'); // amount deducted per payroll period
$table->integer('installments_count'); // total number of installments
$table->integer('installments_paid')->default(0);
$table->text('reason')->nullable();
$table->string('status', 20)->default('active');
$table->date('issued_date');
$table->date('expected_completion_date')->nullable();
$table->foreignId('approved_by')->nullable()->constrained('users');
$table->text('notes')->nullable();
$table->jsonb('metadata')->default('{}');
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->index(['academy_id', 'trainer_id', 'status']);
$table->index(['academy_id', 'status']);
});
DB::statement("ALTER TABLE trainer_advances ADD CONSTRAINT trainer_advances_status_check CHECK (status IN ('active', 'fully_deducted', 'cancelled', 'paused'))");
}
public function down(): void
{
Schema::dropIfExists('trainer_advances');
}
};
<?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_rate_history', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('trainer_id')->constrained('trainers');
$table->string('field', 30); // which rate field changed: hourly_rate, session_rate, group_rate, player_rate, revenue_share_percent
$table->string('old_value', 50)->nullable(); // stored as string for flexibility
$table->string('new_value', 50);
$table->date('effective_from');
$table->foreignId('changed_by')->constrained('users');
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['trainer_id', 'field', 'effective_from']);
});
}
public function down(): void
{
Schema::dropIfExists('trainer_rate_history');
}
};
......@@ -31,6 +31,8 @@
['section' => 'الموارد البشرية', 'items' => [
['label' => 'الموظفين', 'route' => 'employees.list', 'icon' => 'briefcase', 'permission' => 'employees.list'],
['label' => 'المدربين', 'route' => 'trainers.list', 'icon' => 'academic-cap', 'permission' => 'trainers.list'],
['label' => 'الرواتب', 'route' => 'payroll.dashboard', 'icon' => 'banknotes', 'permission' => 'payroll.manage'],
['label' => 'السلف', 'route' => 'payroll.advances', 'icon' => 'arrow-trending-up', 'permission' => 'payroll.manage'],
]],
['section' => 'المالية', 'items' => [
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -278,6 +278,16 @@
Route::get('/hr/trainers/{trainer}/edit', \App\Livewire\HR\TrainerForm::class)->name('trainers.edit')
->middleware('permission:trainers.update');
// HR - Payroll
Route::get('/hr/payroll', \App\Livewire\HR\PayrollDashboard::class)->name('payroll.dashboard')
->middleware('permission:payroll.manage');
Route::get('/hr/payroll/trainer/{trainer}/compensations', \App\Livewire\HR\TrainerCompensations::class)->name('payroll.trainer-compensations')
->middleware('permission:payroll.manage');
Route::get('/hr/payroll/payslip/{payslip}', \App\Livewire\HR\PayslipDetail::class)->name('payroll.payslip-detail')
->middleware('permission:payroll.manage');
Route::get('/hr/advances', \App\Livewire\HR\TrainerAdvances::class)->name('payroll.advances')
->middleware('permission:payroll.manage');
// Assignments
Route::get('/assignments', AssignmentList::class)->name('assignments.list')
->middleware('permission:assignments.list');
......
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