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');
}
}
<?php
namespace App\Domain\HR\Services;
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 App\Domain\Shared\Services\SettingsService;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class CompensationCalculatorService
{
public function __construct(
private SettingsService $settings,
) {}
/**
* Calculate compensation for a trainer attending a session.
* Called when attendance is marked (present, late, or substitute).
*/
public function calculateForSession(
Trainer $trainer,
int $trainingSessionId,
int $attendanceRecordId,
string $attendanceStatus, // 'present', 'late', 'substitute'
User $actor,
): ?TrainerCompensation {
// Don't double-create
$existing = TrainerCompensation::where('trainer_id', $trainer->id)
->where('training_session_id', $trainingSessionId)
->whereNotIn('status', [CompensationStatus::Cancelled->value])
->first();
if ($existing) {
return $existing;
}
$rate = $this->resolveSessionRate($trainer);
if ($rate === 0) {
return null; // Salaried trainer with no per-session rate
}
$type = match ($attendanceStatus) {
'substitute' => CompensationType::Substitute,
default => CompensationType::SessionPay,
};
return DB::transaction(function () use ($trainer, $trainingSessionId, $attendanceRecordId, $rate, $type, $actor) {
return TrainerCompensation::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'training_session_id' => $trainingSessionId,
'attendance_record_id' => $attendanceRecordId,
'date' => now()->toDateString(),
'type' => $type,
'description' => $type->label(),
'quantity' => 1,
'rate' => $rate,
'amount' => $rate, // 1 × rate
'status' => CompensationStatus::Pending,
'created_by' => $actor->id,
]);
});
}
/**
* Calculate penalty for a no-show (absent without excuse).
*/
public function calculatePenalty(
Trainer $trainer,
int $trainingSessionId,
int $attendanceRecordId,
User $actor,
): ?TrainerCompensation {
$penaltyAmount = (int) $this->settings->get('absence_penalty_amount', 10000);
if ($penaltyAmount <= 0) {
return null;
}
$existing = TrainerCompensation::where('trainer_id', $trainer->id)
->where('training_session_id', $trainingSessionId)
->where('type', CompensationType::Penalty->value)
->whereNotIn('status', [CompensationStatus::Cancelled->value])
->first();
if ($existing) {
return $existing;
}
return DB::transaction(function () use ($trainer, $trainingSessionId, $attendanceRecordId, $penaltyAmount, $actor) {
return TrainerCompensation::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'training_session_id' => $trainingSessionId,
'attendance_record_id' => $attendanceRecordId,
'date' => now()->toDateString(),
'type' => CompensationType::Penalty,
'description' => 'خصم غياب بدون عذر',
'quantity' => 1,
'rate' => $penaltyAmount,
'amount' => $penaltyAmount,
'status' => CompensationStatus::Pending,
'created_by' => $actor->id,
]);
});
}
/**
* Calculate late penalty (less than full absence).
*/
public function calculateLatePenalty(
Trainer $trainer,
int $trainingSessionId,
int $attendanceRecordId,
User $actor,
): ?TrainerCompensation {
$latePenalty = (int) $this->settings->get('late_penalty_amount', 5000);
if ($latePenalty <= 0) {
return null;
}
$existing = TrainerCompensation::where('trainer_id', $trainer->id)
->where('training_session_id', $trainingSessionId)
->where('type', CompensationType::Penalty->value)
->whereNotIn('status', [CompensationStatus::Cancelled->value])
->first();
if ($existing) {
return $existing;
}
return DB::transaction(function () use ($trainer, $trainingSessionId, $attendanceRecordId, $latePenalty, $actor) {
return TrainerCompensation::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'training_session_id' => $trainingSessionId,
'attendance_record_id' => $attendanceRecordId,
'date' => now()->toDateString(),
'type' => CompensationType::Penalty,
'description' => 'خصم تأخير',
'quantity' => 1,
'rate' => $latePenalty,
'amount' => $latePenalty,
'status' => CompensationStatus::Pending,
'created_by' => $actor->id,
]);
});
}
/**
* Calculate compensation when academy cancels a session.
* Trainer gets a configurable % of their rate.
*/
public function calculateCancelledSessionPay(
Trainer $trainer,
int $trainingSessionId,
User $actor,
): ?TrainerCompensation {
$payPercent = (int) $this->settings->get('cancelled_session_pay_percent', 50);
if ($payPercent <= 0) {
return null;
}
$rate = $this->resolveSessionRate($trainer);
if ($rate === 0) {
return null;
}
$amount = (int) round($rate * $payPercent / 100);
return DB::transaction(function () use ($trainer, $trainingSessionId, $amount, $rate, $payPercent, $actor) {
return TrainerCompensation::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'training_session_id' => $trainingSessionId,
'attendance_record_id' => null,
'date' => now()->toDateString(),
'type' => CompensationType::CancelledSession,
'description' => "تعويض حصة ملغاة ({$payPercent}%)",
'quantity' => 1,
'rate' => $rate,
'amount' => $amount,
'status' => CompensationStatus::Pending,
'metadata' => ['pay_percent' => $payPercent],
'created_by' => $actor->id,
]);
});
}
/**
* Calculate monthly per-player compensation.
* Called during payroll period calculation.
*/
public function calculatePlayerPay(
Trainer $trainer,
Carbon $periodStart,
Carbon $periodEnd,
User $actor,
): ?TrainerCompensation {
if (!$trainer->player_rate || $trainer->player_rate <= 0) {
return null;
}
if (!in_array($trainer->compensation_model->value, ['per_player', 'hybrid'])) {
return null;
}
// Resolve trainer's user_id (head_trainer_id on groups references users.id)
$userId = $trainer->employee?->user_id;
if (!$userId) {
return null;
}
// Count active enrollments in trainer's assigned groups during this period
$playerCount = \App\Domain\Training\Models\TrainingGroup::where('head_trainer_id', $userId)
->where('status', 'active')
->withCount(['enrollments' => function ($q) {
$q->where('status', 'active');
}])
->get()
->sum('enrollments_count');
if ($playerCount <= 0) {
return null;
}
$amount = $playerCount * $trainer->player_rate;
return DB::transaction(function () use ($trainer, $periodStart, $periodEnd, $playerCount, $amount, $actor) {
return TrainerCompensation::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'training_session_id' => null,
'attendance_record_id' => null,
'date' => $periodEnd->toDateString(),
'type' => CompensationType::PlayerPay,
'description' => "أجر لاعبين ({$playerCount} لاعب)",
'quantity' => $playerCount,
'rate' => $trainer->player_rate,
'amount' => $amount,
'status' => CompensationStatus::Pending,
'metadata' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'player_count' => $playerCount,
],
'created_by' => $actor->id,
]);
});
}
/**
* Calculate monthly revenue share compensation.
* Called during payroll period calculation.
*/
public function calculateRevenueShare(
Trainer $trainer,
Carbon $periodStart,
Carbon $periodEnd,
User $actor,
): ?TrainerCompensation {
if (!$trainer->revenue_share_percent || $trainer->revenue_share_percent <= 0) {
return null;
}
if (!in_array($trainer->compensation_model->value, ['revenue_share', 'hybrid'])) {
return null;
}
// Sum payments received for groups where trainer is head_trainer
$userId = $trainer->employee?->user_id;
if (!$userId) {
return null;
}
$groupIds = \App\Domain\Training\Models\TrainingGroup::where('head_trainer_id', $userId)
->pluck('id');
if ($groupIds->isEmpty()) {
return null;
}
// Get revenue from invoices paid during this period for these groups
$revenue = \App\Domain\Financial\Models\Payment::whereHas('invoice', function ($q) use ($groupIds) {
$q->whereHas('items', function ($q2) use ($groupIds) {
$q2->whereIn('metadata->training_group_id', $groupIds);
});
})
->whereBetween('created_at', [$periodStart, $periodEnd])
->where('status', 'confirmed')
->sum('amount');
if ($revenue <= 0) {
return null;
}
$shareAmount = (int) round($revenue * $trainer->revenue_share_percent / 100);
return DB::transaction(function () use ($trainer, $periodStart, $periodEnd, $revenue, $shareAmount, $actor) {
return TrainerCompensation::create([
'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id,
'training_session_id' => null,
'attendance_record_id' => null,
'date' => $periodEnd->toDateString(),
'type' => CompensationType::RevenueShare,
'description' => "حصة إيرادات ({$trainer->revenue_share_percent}%)",
'quantity' => 1,
'rate' => $revenue,
'amount' => $shareAmount,
'status' => CompensationStatus::Pending,
'metadata' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'total_revenue' => $revenue,
'share_percent' => $trainer->revenue_share_percent,
],
'created_by' => $actor->id,
]);
});
}
/**
* Resolve the per-session rate for a trainer based on compensation model.
*/
private function resolveSessionRate(Trainer $trainer): int
{
return match ($trainer->compensation_model->value) {
'hourly' => $trainer->hourly_rate ?? 0,
'per_session' => $trainer->session_rate ?? 0,
'per_group' => $trainer->group_rate ?? 0,
'hybrid' => $trainer->session_rate ?? $trainer->hourly_rate ?? 0,
'salary' => 0, // Salaried trainers don't get per-session pay
'contract' => $trainer->session_rate ?? 0,
default => $trainer->session_rate ?? 0,
};
}
}
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Enums\AdvanceStatus;
use App\Domain\HR\Enums\CompensationStatus;
use App\Domain\HR\Enums\PayrollPeriodStatus;
use App\Domain\HR\Enums\PayslipItemType;
use App\Domain\HR\Enums\PayslipStatus;
use App\Domain\HR\Models\PayrollPeriod;
use App\Domain\HR\Models\Payslip;
use App\Domain\HR\Models\PayslipItem;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\HR\Models\TrainerCompensation;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class PayrollService
{
public function __construct(
private CompensationCalculatorService $compensationCalculator,
private SettingsService $settings,
) {}
/**
* Create or get the current open payroll period.
*/
public function getOrCreateCurrentPeriod(int $academyId, User $actor): PayrollPeriod
{
$now = now();
$payrollDay = (int) $this->settings->get('payroll_day', 28);
// Period runs from payroll_day of previous month to (payroll_day - 1) of current month
if ($now->day >= $payrollDay) {
$periodStart = $now->copy()->day($payrollDay);
$periodEnd = $now->copy()->addMonth()->day($payrollDay)->subDay();
} else {
$periodStart = $now->copy()->subMonth()->day($payrollDay);
$periodEnd = $now->copy()->day($payrollDay)->subDay();
}
$existing = PayrollPeriod::where('academy_id', $academyId)
->where('period_start', $periodStart->toDateString())
->first();
if ($existing) {
return $existing;
}
return PayrollPeriod::create([
'academy_id' => $academyId,
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'status' => PayrollPeriodStatus::Open,
'created_by' => $actor->id,
]);
}
/**
* Calculate payslips for all active trainers in a period.
* This is the big monthly operation.
*/
public function calculatePeriod(PayrollPeriod $period, User $actor): PayrollPeriod
{
return DB::transaction(function () use ($period, $actor) {
$period->update(['status' => PayrollPeriodStatus::Calculating]);
$academyId = $period->academy_id;
$periodStart = Carbon::parse($period->period_start);
$periodEnd = Carbon::parse($period->period_end);
// Get all active trainers
$trainers = Trainer::where('academy_id', $academyId)
->where('status', 'active')
->get();
// Calculate monthly items (player pay, revenue share) for each trainer
foreach ($trainers as $trainer) {
$this->compensationCalculator->calculatePlayerPay($trainer, $periodStart, $periodEnd, $actor);
$this->compensationCalculator->calculateRevenueShare($trainer, $periodStart, $periodEnd, $actor);
}
$totalGross = 0;
$totalDeductions = 0;
$totalNet = 0;
$payslipCount = 0;
foreach ($trainers as $trainer) {
$payslip = $this->generatePayslip($trainer, $period, $periodStart, $periodEnd, $actor);
if ($payslip) {
$totalGross += $payslip->gross_amount;
$totalDeductions += $payslip->total_deductions;
$totalNet += $payslip->net_amount;
$payslipCount++;
}
}
$period->update([
'status' => PayrollPeriodStatus::Review,
'total_gross' => $totalGross,
'total_deductions' => $totalDeductions,
'total_net' => $totalNet,
'payslip_count' => $payslipCount,
]);
return $period->fresh();
});
}
/**
* Generate a single trainer's payslip for a period.
*/
public function generatePayslip(
Trainer $trainer,
PayrollPeriod $period,
Carbon $periodStart,
Carbon $periodEnd,
User $actor,
): ?Payslip {
// Get all pending/approved compensation records for this trainer in this period
$compensations = TrainerCompensation::where('trainer_id', $trainer->id)
->whereBetween('date', [$periodStart->toDateString(), $periodEnd->toDateString()])
->whereIn('status', [CompensationStatus::Pending->value, CompensationStatus::Approved->value])
->get();
// Get base salary (if salaried/hybrid)
$baseSalary = 0;
if (in_array($trainer->compensation_model->value, ['salary', 'hybrid'])) {
$employee = $trainer->employee;
if ($employee && $employee->salary_amount) {
$baseSalary = $employee->salary_amount;
}
}
// Skip if nothing to pay
$hasCompensation = $compensations->isNotEmpty() || $baseSalary > 0;
if (!$hasCompensation) {
return null;
}
// Delete existing draft payslip for this trainer + period (recalculation)
Payslip::where('trainer_id', $trainer->id)
->where('payroll_period_id', $period->id)
->where('status', PayslipStatus::Draft->value)
->delete();
// Separate earnings from penalties
$sessionEarnings = $compensations->filter(fn ($c) => $c->type->isEarning())->sum('amount');
$penalties = $compensations->filter(fn ($c) => !$c->type->isEarning())->sum('amount');
// Advance deduction
$advanceDeduction = $this->calculateAdvanceDeduction($trainer);
// Insurance — Egypt: 11% employee share applied to base salary only
$insurancePercent = (float) $this->settings->get('social_insurance_percent', 11);
$insuranceAmount = 0;
if ($baseSalary > 0 && $insurancePercent > 0) {
$insuranceAmount = (int) round($baseSalary * $insurancePercent / 100);
}
// Tax — simplified: Egyptian tax bracket implementation pending
$taxAmount = 0; // TODO: implement Egyptian tax brackets
$grossAmount = $baseSalary + $sessionEarnings;
$bonuses = 0; // Manual bonuses are added separately
$otherDeductions = 0;
$totalDeductions = $penalties + $advanceDeduction + $taxAmount + $insuranceAmount + $otherDeductions;
$netAmount = max(0, $grossAmount + $bonuses - $totalDeductions);
// Generate payslip number
$payslipNumber = $this->generatePayslipNumber($period->academy_id, $periodEnd);
$payslip = Payslip::create([
'academy_id' => $period->academy_id,
'trainer_id' => $trainer->id,
'payroll_period_id' => $period->id,
'payslip_number' => $payslipNumber,
'base_amount' => $baseSalary,
'session_earnings' => $sessionEarnings,
'bonuses' => $bonuses,
'penalties' => $penalties,
'advances_deducted' => $advanceDeduction,
'tax_amount' => $taxAmount,
'insurance_amount' => $insuranceAmount,
'other_deductions' => $otherDeductions,
'gross_amount' => $grossAmount,
'total_deductions' => $totalDeductions,
'net_amount' => $netAmount,
'status' => PayslipStatus::Draft,
'created_by' => $actor->id,
]);
// ─── Line Items ──────────────────────────────────────────────
$sortOrder = 0;
// Base salary line
if ($baseSalary > 0) {
PayslipItem::create([
'payslip_id' => $payslip->id,
'type' => PayslipItemType::BaseSalary,
'description' => 'الراتب الأساسي',
'quantity' => 1,
'rate' => $baseSalary,
'amount' => $baseSalary,
'is_deduction' => false,
'sort_order' => $sortOrder++,
]);
}
// Group compensation records by type and create one line item per type
$grouped = $compensations->groupBy(fn ($c) => $c->type->value);
foreach ($grouped as $typeValue => $items) {
$itemType = $this->mapCompTypeToPayslipType($typeValue);
$isDeduction = $itemType->isDeduction();
$totalAmount = $items->sum('amount');
$count = $items->count();
$avgRate = $count > 0 ? (int) round($totalAmount / $count) : 0;
PayslipItem::create([
'payslip_id' => $payslip->id,
'type' => $itemType,
'description' => $items->first()->type->label() . " ({$count})",
'quantity' => $count,
'rate' => $avgRate,
'amount' => $isDeduction ? -$totalAmount : $totalAmount,
'is_deduction' => $isDeduction,
'sort_order' => $sortOrder++,
]);
}
// Advance deduction line
if ($advanceDeduction > 0) {
PayslipItem::create([
'payslip_id' => $payslip->id,
'type' => PayslipItemType::AdvanceDeduction,
'description' => 'قسط سلفة',
'quantity' => 1,
'rate' => $advanceDeduction,
'amount' => -$advanceDeduction,
'is_deduction' => true,
'sort_order' => $sortOrder++,
]);
}
// Insurance line
if ($insuranceAmount > 0) {
PayslipItem::create([
'payslip_id' => $payslip->id,
'type' => PayslipItemType::Insurance,
'description' => "تأمينات اجتماعية ({$insurancePercent}%)",
'quantity' => 1,
'rate' => $insuranceAmount,
'amount' => -$insuranceAmount,
'is_deduction' => true,
'sort_order' => $sortOrder++,
]);
}
return $payslip;
}
/**
* Approve a single payslip.
*/
public function approvePayslip(Payslip $payslip, User $actor): Payslip
{
if (
$payslip->status !== PayslipStatus::Draft &&
$payslip->status !== PayslipStatus::PendingApproval
) {
throw new DomainException('لا يمكن الموافقة على هذا الكشف في حالته الحالية');
}
return DB::transaction(function () use ($payslip, $actor) {
$payslip->update([
'status' => PayslipStatus::Approved,
'approved_by' => $actor->id,
'approved_at' => now(),
]);
// Mark all related compensation records as approved
TrainerCompensation::where('trainer_id', $payslip->trainer_id)
->whereBetween('date', [
$payslip->period->period_start,
$payslip->period->period_end,
])
->where('status', CompensationStatus::Pending->value)
->update([
'status' => CompensationStatus::Approved,
'approved_by' => $actor->id,
'approved_at' => now(),
]);
return $payslip->fresh();
});
}
/**
* Mark a payslip as paid and create the financial transaction.
*/
public function markPaid(
Payslip $payslip,
string $paymentMethod,
?string $paymentReference,
User $actor,
): Payslip {
if ($payslip->status !== PayslipStatus::Approved) {
throw new DomainException('يجب الموافقة على الكشف قبل تسجيل الدفع');
}
return DB::transaction(function () use ($payslip, $paymentMethod, $paymentReference, $actor) {
$payslip->update([
'status' => PayslipStatus::Paid,
'paid_at' => now(),
'payment_method' => $paymentMethod,
'payment_reference' => $paymentReference,
]);
// Mark related compensation records as paid
TrainerCompensation::where('trainer_id', $payslip->trainer_id)
->whereBetween('date', [
$payslip->period->period_start,
$payslip->period->period_end,
])
->whereIn('status', [CompensationStatus::Pending->value, CompensationStatus::Approved->value])
->update(['status' => CompensationStatus::Paid]);
// Deduct outstanding advance installment
if ($payslip->advances_deducted > 0) {
$this->applyAdvanceDeduction($payslip->trainer_id, $payslip->advances_deducted);
}
// TODO: Create double-entry financial transaction
// Debit: Salary Expense account
// Credit: Cash / Bank account (derived from $paymentMethod)
return $payslip->fresh();
});
}
/**
* Approve an entire payroll period (moves all draft payslips to pending_approval).
*/
public function approvePeriod(PayrollPeriod $period, User $actor): PayrollPeriod
{
if ($period->status !== PayrollPeriodStatus::Review) {
throw new DomainException('فترة الرواتب ليست في مرحلة المراجعة');
}
return DB::transaction(function () use ($period, $actor) {
// Move all draft payslips to pending_approval
Payslip::where('payroll_period_id', $period->id)
->where('status', PayslipStatus::Draft->value)
->update(['status' => PayslipStatus::PendingApproval->value]);
$period->update([
'status' => PayrollPeriodStatus::Approved,
'approved_by' => $actor->id,
'approved_at' => now(),
]);
return $period->fresh();
});
}
/**
* Close a payroll period. All payslips must be paid first.
*/
public function closePeriod(PayrollPeriod $period, User $actor): PayrollPeriod
{
$unpaid = Payslip::where('payroll_period_id', $period->id)
->whereNotIn('status', [PayslipStatus::Paid->value, PayslipStatus::Cancelled->value])
->count();
if ($unpaid > 0) {
throw new DomainException("لا يمكن إغلاق الفترة — يوجد {$unpaid} كشف غير مدفوع");
}
return DB::transaction(function () use ($period, $actor) {
$period->update([
'status' => PayrollPeriodStatus::Closed,
'closed_at' => now(),
]);
return $period->fresh();
});
}
// ─── Private Helpers ────────────────────────────────────────────
/**
* Calculate how much to deduct from the trainer's active advance this cycle.
*/
private function calculateAdvanceDeduction(Trainer $trainer): int
{
$activeAdvance = TrainerAdvance::where('trainer_id', $trainer->id)
->where('status', AdvanceStatus::Active->value)
->first();
if (!$activeAdvance) {
return 0;
}
// Deduct the installment amount, but never more than the remaining balance
return min($activeAdvance->installment_amount, $activeAdvance->remaining_balance);
}
/**
* Apply a deduction against the trainer's active advance and update its state.
*/
private function applyAdvanceDeduction(int $trainerId, int $amount): void
{
$advance = TrainerAdvance::where('trainer_id', $trainerId)
->where('status', AdvanceStatus::Active->value)
->first();
if (!$advance) {
return;
}
$newBalance = max(0, $advance->remaining_balance - $amount);
$newInstallments = $advance->installments_paid + 1;
$advance->update([
'remaining_balance' => $newBalance,
'installments_paid' => $newInstallments,
'status' => $newBalance <= 0 ? AdvanceStatus::FullyDeducted : AdvanceStatus::Active,
]);
}
/**
* Generate a unique payslip number in the format PS{ym}{seq}.
*/
private function generatePayslipNumber(int $academyId, Carbon $date): string
{
$prefix = 'PS';
$month = $date->format('ym');
$count = Payslip::where('academy_id', $academyId)
->where('payslip_number', 'like', "{$prefix}{$month}%")
->count();
$seq = str_pad($count + 1, 4, '0', STR_PAD_LEFT);
return "{$prefix}{$month}{$seq}";
}
/**
* Map a CompensationType value to the corresponding PayslipItemType.
*/
private function mapCompTypeToPayslipType(string $compType): PayslipItemType
{
return match ($compType) {
'session_pay' => PayslipItemType::SessionPay,
'group_pay' => PayslipItemType::GroupPay,
'player_pay' => PayslipItemType::PlayerPay,
'revenue_share' => PayslipItemType::RevenueShare,
'bonus' => PayslipItemType::Bonus,
'penalty' => PayslipItemType::Penalty,
'overtime' => PayslipItemType::Overtime,
'substitute' => PayslipItemType::Substitute,
'cancelled_session' => PayslipItemType::CancelledSessionPay,
default => PayslipItemType::SessionPay,
};
}
}
<?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' => [
......
<div x-data="{ activeTab: @entangle('activeTab') }">
{{-- ─── Header ───────────────────────────────────────────────────────── --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ __('إدارة الرواتب') }}</h1>
<p class="text-sm text-gray-500 mt-0.5">{{ __('إدارة دورات الرواتب وكشوف رواتب المدربين') }}</p>
</div>
@can('payroll.manage')
<button
wire:click="createPeriod"
wire:loading.attr="disabled"
wire:target="createPeriod"
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 disabled:opacity-60 transition-colors"
>
<span wire:loading.remove wire:target="createPeriod">
<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>
</span>
<span wire:loading wire:target="createPeriod">
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</span>
<span wire:loading.remove wire:target="createPeriod">{{ __('فترة رواتب جديدة') }}</span>
<span wire:loading wire:target="createPeriod">{{ __('جارٍ الإنشاء...') }}</span>
</button>
@endcan
</div>
{{-- ─── Flash Messages ───────────────────────────────────────────────── --}}
@if(session('success'))
<div class="mb-5 flex items-center gap-3 p-4 bg-green-50 border border-green-200 rounded-xl text-green-800 text-sm">
<svg class="w-5 h-5 shrink-0 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="mb-5 flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-xl text-red-800 text-sm">
<svg class="w-5 h-5 shrink-0 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ session('error') }}
</div>
@endif
{{-- ─── Stats Row ────────────────────────────────────────────────────── --}}
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{{-- إجمالي الرواتب --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-500">{{ __('إجمالي رواتب الفترة') }}</span>
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900">{{ number_format($totalPayrollThisMonth / 100, 2) }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('ج.م') }}</p>
</div>
{{-- بانتظار الموافقة --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-500">{{ __('بانتظار الموافقة') }}</span>
<div class="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900">{{ $pendingApprovals }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('كشف راتب') }}</p>
</div>
{{-- مدفوع هذا الشهر --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-500">{{ __('مدفوع هذا الشهر') }}</span>
<div class="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900">{{ number_format($paidThisMonth / 100, 2) }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('ج.م') }}</p>
</div>
{{-- مدربين نشطين --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-500">{{ __('مدربين نشطين') }}</span>
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
</div>
<p class="text-2xl font-bold text-gray-900">{{ $activeTrainers }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('مدرب') }}</p>
</div>
</div>
{{-- ─── Tabs ─────────────────────────────────────────────────────────── --}}
<div class="border-b border-gray-200 mb-5">
<nav class="flex gap-1">
<button
@click="activeTab = 'periods'"
:class="activeTab === 'periods'
? 'border-b-2 border-blue-600 text-blue-600 font-semibold'
: 'text-gray-500 hover:text-gray-700'"
class="px-4 py-3 text-sm transition-colors whitespace-nowrap"
>
{{ __('فترات الرواتب') }}
</button>
<button
@click="activeTab = 'payslips'"
:class="activeTab === 'payslips'
? 'border-b-2 border-blue-600 text-blue-600 font-semibold'
: 'text-gray-500 hover:text-gray-700'"
class="px-4 py-3 text-sm transition-colors whitespace-nowrap"
>
{{ __('كشوف الرواتب') }}
@if($pendingApprovals > 0)
<span class="ms-1.5 inline-flex items-center justify-center w-5 h-5 rounded-full bg-amber-100 text-amber-700 text-xs font-bold">{{ $pendingApprovals }}</span>
@endif
</button>
</nav>
</div>
{{-- ─────────────────────────────────────────────────────────────────── --}}
{{-- TAB: Payroll Periods --}}
{{-- ─────────────────────────────────────────────────────────────────── --}}
<div x-show="activeTab === 'periods'" x-cloak>
{{-- Filter bar --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-3">
<select
wire:model.live="periodFilter"
class="w-full sm:w-56 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{{ __('كل الفترات') }}</option>
@foreach($periodStatuses as $s)
@php
$label = match($s->value) {
'open' => 'مفتوحة',
'calculating' => 'جارٍ الاحتساب',
'review' => 'قيد المراجعة',
'approved' => 'موافق عليها',
'closed' => 'مغلقة',
default => $s->value,
};
@endphp
<option value="{{ $s->value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
{{-- Periods 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" wire:target="periodFilter,calculatePeriod,approvePeriod,closePeriod,createPeriod">
<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-end font-semibold text-gray-600">{{ __('إجمالي الإجمالي') }}</th>
<th class="px-4 py-3 text-end font-semibold text-gray-600">{{ __('إجمالي الصافي') }}</th>
<th class="px-4 py-3 text-center 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($periods as $period)
@php
$statusConfig = match($period->status->value) {
'open' => ['label' => 'مفتوحة', 'class' => 'bg-sky-100 text-sky-800'],
'calculating' => ['label' => 'جارٍ الاحتساب', 'class' => 'bg-yellow-100 text-yellow-800'],
'review' => ['label' => 'قيد المراجعة', 'class' => 'bg-amber-100 text-amber-800'],
'approved' => ['label' => 'موافق عليها', 'class' => 'bg-blue-100 text-blue-800'],
'closed' => ['label' => 'مغلقة', 'class' => 'bg-gray-100 text-gray-600'],
default => ['label' => $period->status->value, 'class' => 'bg-gray-100 text-gray-600'],
};
@endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<p class="font-medium text-gray-900">
{{ $period->period_start->format('d/m/Y') }}
<span class="text-gray-400 mx-1"></span>
{{ $period->period_end->format('d/m/Y') }}
</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ __('أُنشئت:') }} {{ $period->created_at->format('d/m/Y') }}
</p>
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium {{ $statusConfig['class'] }}">
{{ $statusConfig['label'] }}
</span>
</td>
<td class="px-4 py-3 text-end font-medium text-gray-800">
{{ $period->total_gross ? number_format($period->total_gross / 100, 2) . ' ج.م' : '-' }}
</td>
<td class="px-4 py-3 text-end font-semibold text-green-700">
{{ $period->total_net ? number_format($period->total_net / 100, 2) . ' ج.م' : '-' }}
</td>
<td class="px-4 py-3 text-center text-gray-600">
{{ $period->payslip_count ?? 0 }}
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
@if($period->status->value === 'open')
<button
wire:click="calculatePeriod({{ $period->id }})"
wire:loading.attr="disabled"
wire:target="calculatePeriod({{ $period->id }})"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors"
>
<span wire:loading.remove wire:target="calculatePeriod({{ $period->id }})">{{ __('احتساب') }}</span>
<span wire:loading wire:target="calculatePeriod({{ $period->id }})">{{ __('جارٍ...') }}</span>
</button>
@elseif($period->status->value === 'review')
<button
wire:click="approvePeriod({{ $period->id }})"
wire:loading.attr="disabled"
wire:target="approvePeriod({{ $period->id }})"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 disabled:opacity-60 transition-colors"
>
<span wire:loading.remove wire:target="approvePeriod({{ $period->id }})">{{ __('اعتماد') }}</span>
<span wire:loading wire:target="approvePeriod({{ $period->id }})">{{ __('جارٍ...') }}</span>
</button>
@elseif($period->status->value === 'approved')
<button
wire:click="closePeriod({{ $period->id }})"
wire:loading.attr="disabled"
wire:target="closePeriod({{ $period->id }})"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gray-600 text-white text-xs font-medium rounded-lg hover:bg-gray-700 disabled:opacity-60 transition-colors"
>
<span wire:loading.remove wire:target="closePeriod({{ $period->id }})">{{ __('إغلاق') }}</span>
<span wire:loading wire:target="closePeriod({{ $period->id }})">{{ __('جارٍ...') }}</span>
</button>
@elseif($period->status->value === 'closed')
<span class="text-xs text-gray-400">{{ __('مغلقة') }}</span>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-16 text-center">
<svg class="w-14 h-14 text-gray-200 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="font-semibold text-gray-500">{{ __('لا توجد فترات رواتب') }}</p>
<p class="text-sm text-gray-400 mt-1">{{ __('اضغط على "فترة رواتب جديدة" لإنشاء الفترة الحالية') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($periods->hasPages())
<div class="px-4 py-3 border-t border-gray-200">
{{ $periods->links() }}
</div>
@endif
</div>
</div>
{{-- ─────────────────────────────────────────────────────────────────── --}}
{{-- TAB: Payslips --}}
{{-- ─────────────────────────────────────────────────────────────────── --}}
<div x-show="activeTab === 'payslips'" x-cloak>
{{-- Search & Filter bar --}}
<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 xl: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="payslipStatus"
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"
>
<option value="">{{ __('كل الحالات') }}</option>
@foreach($payslipStatuses as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</select>
</div>
</div>
{{-- Payslips 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" wire:target="search,payslipStatus,approvePayslip,confirmMarkPaid">
<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-end font-semibold text-gray-600">{{ __('الإجمالي') }}</th>
<th class="px-4 py-3 text-end font-semibold text-gray-600">{{ __('الخصومات') }}</th>
<th class="px-4 py-3 text-end 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($payslips as $payslip)
@php
$badgeClass = match($payslip->status->value) {
'draft' => 'bg-gray-100 text-gray-600',
'pending_approval' => 'bg-amber-100 text-amber-800',
'approved' => 'bg-blue-100 text-blue-800',
'paid' => 'bg-green-100 text-green-800',
'cancelled' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-600',
};
@endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<p class="font-medium text-gray-900">
{{ $payslip->trainer->employee->person->name_ar ?? '-' }}
</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ $payslip->trainer->employee->branch?->name_ar ?? '' }}
</p>
</td>
<td class="px-4 py-3 text-gray-600 text-xs">
@if($payslip->period)
{{ $payslip->period->period_start->format('d/m/Y') }}
<br>
{{ $payslip->period->period_end->format('d/m/Y') }}
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600">
{{ $payslip->payslip_number }}
</td>
<td class="px-4 py-3 text-end text-gray-800">
{{ number_format($payslip->gross_amount / 100, 2) }} <span class="text-xs text-gray-400">ج.م</span>
</td>
<td class="px-4 py-3 text-end text-red-600">
@if($payslip->total_deductions > 0)
({{ number_format($payslip->total_deductions / 100, 2) }}) <span class="text-xs text-red-400">ج.م</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-end font-semibold text-green-700">
{{ number_format($payslip->net_amount / 100, 2) }} <span class="text-xs text-green-500">ج.م</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium {{ $badgeClass }}">
{{ $payslip->status->label() }}
</span>
@if($payslip->status->value === 'paid' && $payslip->paid_at)
<p class="text-xs text-gray-400 mt-0.5">{{ $payslip->paid_at->format('d/m/Y') }}</p>
@endif
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
@if(in_array($payslip->status->value, ['draft', 'pending_approval']))
@can('payroll.manage')
<button
wire:click="approvePayslip({{ $payslip->id }})"
wire:loading.attr="disabled"
wire:target="approvePayslip({{ $payslip->id }})"
class="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 border border-blue-200 text-xs font-medium rounded-lg hover:bg-blue-100 disabled:opacity-60 transition-colors"
>
<span wire:loading.remove wire:target="approvePayslip({{ $payslip->id }})">{{ __('اعتماد') }}</span>
<span wire:loading wire:target="approvePayslip({{ $payslip->id }})">...</span>
</button>
@endcan
@endif
@if($payslip->status->value === 'approved')
@can('payroll.manage')
<button
wire:click="openMarkPaidModal({{ $payslip->id }})"
class="inline-flex items-center gap-1 px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 text-xs font-medium rounded-lg hover:bg-green-100 transition-colors"
>
{{ __('تسجيل دفع') }}
</button>
@endcan
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-16 text-center">
<svg class="w-14 h-14 text-gray-200 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="font-semibold text-gray-500">{{ __('لا توجد كشوف رواتب') }}</p>
<p class="text-sm text-gray-400 mt-1">
@if($search || $payslipStatus)
{{ __('لا توجد نتائج تطابق معايير البحث') }}
@else
{{ __('قم بإنشاء فترة رواتب واحتسابها لتوليد الكشوف') }}
@endif
</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($payslips->hasPages())
<div class="px-4 py-3 border-t border-gray-200">
{{ $payslips->links() }}
</div>
@endif
</div>
</div>
{{-- ─── Mark Paid Modal ──────────────────────────────────────────────── --}}
@if($showMarkPaidModal)
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
x-data
@keydown.escape.window="$wire.closeModal()"
>
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
{{-- Modal Header --}}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-bold text-gray-900">{{ __('تسجيل دفع الراتب') }}</h3>
<button
wire:click="closeModal"
class="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{{-- Modal Body --}}
<div class="px-6 py-5 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
{{ __('طريقة الدفع') }}
<span class="text-red-500">*</span>
</label>
<select
wire:model="markPaidMethod"
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"
>
@foreach($paymentMethods as $method)
<option value="{{ $method->value }}">{{ $method->label() }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
{{ __('رقم المرجع / الإيصال') }}
<span class="text-gray-400 text-xs font-normal">({{ __('اختياري') }})</span>
</label>
<input
type="text"
wire:model="markPaidReference"
placeholder="{{ __('مثال: TXN-12345') }}"
dir="ltr"
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"
>
</div>
</div>
{{-- Modal Footer --}}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
<button
wire:click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{{ __('إلغاء') }}
</button>
<button
wire:click="confirmMarkPaid"
wire:loading.attr="disabled"
wire:target="confirmMarkPaid"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-60 transition-colors"
>
<span wire:loading.remove wire:target="confirmMarkPaid">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</span>
<span wire:loading wire:target="confirmMarkPaid">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</span>
<span wire:loading.remove wire:target="confirmMarkPaid">{{ __('تأكيد الدفع') }}</span>
<span wire:loading wire:target="confirmMarkPaid">{{ __('جارٍ التسجيل...') }}</span>
</button>
</div>
</div>
</div>
@endif
</div>
<div x-data="{ showPaymentModal: $wire.entangle('showPaymentModal') }">
{{-- 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
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
{{ session('error') }}
</div>
@endif
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div class="flex items-start gap-3">
<a href="{{ route('trainers.list') }}" wire:navigate
class="inline-flex items-center justify-center w-9 h-9 mt-1 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 text-gray-500 transition-colors flex-shrink-0">
<svg class="w-5 h-5 rotate-180 rtl:rotate-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div>
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900" dir="ltr">
{{ $payslip->payslip_number }}
</h1>
@php
$statusColors = [
'draft' => 'bg-gray-100 text-gray-700',
'pending_approval' => 'bg-amber-100 text-amber-800',
'approved' => 'bg-blue-100 text-blue-800',
'paid' => 'bg-green-100 text-green-800',
'cancelled' => 'bg-red-100 text-red-800',
];
$statusColor = $statusColors[$payslip->status->value] ?? 'bg-gray-100 text-gray-700';
@endphp
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-sm font-medium {{ $statusColor }}">
{{ $payslip->status->label() }}
</span>
</div>
<p class="mt-1 text-gray-600">
{{ $payslip->trainer->employee?->person?->name_ar ?? $payslip->trainer->person?->name_ar ?? __('مدرب غير معروف') }}
</p>
@if($payslip->period)
<p class="text-sm text-gray-400 mt-0.5" dir="ltr">
{{ \Carbon\Carbon::parse($payslip->period->period_start)->format('Y-m-d') }}
&mdash;
{{ \Carbon\Carbon::parse($payslip->period->period_end)->format('Y-m-d') }}
</p>
@endif
</div>
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-2 flex-shrink-0">
@if(in_array($payslip->status->value, ['draft', 'pending_approval']))
<button wire:click="approve"
wire:loading.attr="disabled"
wire:target="approve"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50">
<span wire:loading.remove wire:target="approve">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</span>
<svg wire:loading wire:target="approve" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
<span wire:loading.remove wire:target="approve">{{ __('اعتماد الكشف') }}</span>
<span wire:loading wire:target="approve">{{ __('جارٍ الاعتماد...') }}</span>
</button>
@endif
@if($payslip->status->value === 'approved')
<button @click="showPaymentModal = true"
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
{{ __('تسجيل الدفع') }}
</button>
@endif
</div>
</div>
{{-- Summary Section --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{{-- Gross Amount --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<p class="text-sm text-gray-500 mb-1">{{ __('الإجمالي') }}</p>
<p class="text-2xl font-bold text-gray-900 tabular-nums" dir="ltr">
{{ number_format($payslip->gross_amount / 100, 2) }}
<span class="text-base font-normal text-gray-500">{{ __('ج.م') }}</span>
</p>
@if($payslip->base_amount > 0)
<p class="text-xs text-gray-400 mt-1">
{{ __('راتب أساسي:') }} {{ number_format($payslip->base_amount / 100, 2) }} {{ __('ج.م') }}
</p>
@endif
</div>
{{-- Total Deductions --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<p class="text-sm text-gray-500 mb-1">{{ __('الاستقطاعات') }}</p>
<p class="text-2xl font-bold text-red-700 tabular-nums" dir="ltr">
{{ number_format($payslip->total_deductions / 100, 2) }}
<span class="text-base font-normal text-gray-500">{{ __('ج.م') }}</span>
</p>
@if($payslip->advances_deducted > 0)
<p class="text-xs text-gray-400 mt-1">
{{ __('يشمل سلف:') }} {{ number_format($payslip->advances_deducted / 100, 2) }} {{ __('ج.م') }}
</p>
@endif
</div>
{{-- Net Amount --}}
<div class="bg-blue-600 rounded-xl shadow-sm p-5">
<p class="text-sm text-blue-200 mb-1">{{ __('الصافي المستحق') }}</p>
<p class="text-3xl font-bold text-white tabular-nums" dir="ltr">
{{ number_format($payslip->net_amount / 100, 2) }}
<span class="text-lg font-normal text-blue-200">{{ __('ج.م') }}</span>
</p>
@if($payslip->status->value === 'paid' && $payslip->paid_at)
<p class="text-xs text-blue-200 mt-1">
{{ __('مدفوع في:') }} {{ \Carbon\Carbon::parse($payslip->paid_at)->format('Y-m-d') }}
</p>
@endif
</div>
</div>
{{-- Earnings Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-4 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 bg-gray-50">
<h2 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ __('المكاسب') }}
</h2>
</div>
@if($earningItems->isNotEmpty())
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="px-5 py-2.5 text-start font-medium text-gray-500">{{ __('البند') }}</th>
<th class="px-5 py-2.5 text-end font-medium text-gray-500">{{ __('الكمية') }}</th>
<th class="px-5 py-2.5 text-end font-medium text-gray-500">{{ __('السعر') }}</th>
<th class="px-5 py-2.5 text-end font-medium text-gray-500">{{ __('المبلغ') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach($earningItems as $item)
<tr class="hover:bg-gray-50">
<td class="px-5 py-3 text-gray-800">
{{ $item->description }}
@if($item->type)
<span class="ms-2 text-xs text-gray-400">({{ $item->type->label() }})</span>
@endif
</td>
<td class="px-5 py-3 text-end text-gray-600 tabular-nums" dir="ltr">
{{ number_format((float) $item->quantity, 2) }}
</td>
<td class="px-5 py-3 text-end text-gray-600 tabular-nums whitespace-nowrap" dir="ltr">
@if($item->rate > 0)
{{ number_format($item->rate / 100, 2) }} {{ __('ج.م') }}
@else
@endif
</td>
<td class="px-5 py-3 text-end font-semibold text-gray-900 tabular-nums whitespace-nowrap" dir="ltr">
{{ number_format($item->amount / 100, 2) }} {{ __('ج.م') }}
</td>
</tr>
@endforeach
</tbody>
<tfoot class="bg-green-50 border-t-2 border-green-200">
<tr>
<td colspan="3" class="px-5 py-3 text-sm font-semibold text-green-800">{{ __('إجمالي المكاسب') }}</td>
<td class="px-5 py-3 text-end text-sm font-bold text-green-800 tabular-nums whitespace-nowrap" dir="ltr">
{{ number_format($earningItems->sum('amount') / 100, 2) }} {{ __('ج.م') }}
</td>
</tr>
</tfoot>
</table>
@else
<div class="px-5 py-10 text-center text-sm text-gray-400">
{{ __('لا توجد بنود مكاسب في هذا الكشف') }}
</div>
@endif
</div>
{{-- Deductions Table --}}
<div class="bg-white rounded-xl shadow-sm border border-red-100 mb-6 overflow-hidden">
<div class="px-5 py-4 border-b border-red-100 bg-red-50">
<h2 class="text-sm font-semibold text-red-700 flex items-center gap-2">
<svg class="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ __('الاستقطاعات') }}
</h2>
</div>
@if($deductionItems->isNotEmpty())
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="px-5 py-2.5 text-start font-medium text-gray-500">{{ __('البند') }}</th>
<th class="px-5 py-2.5 text-end font-medium text-gray-500">{{ __('الكمية') }}</th>
<th class="px-5 py-2.5 text-end font-medium text-gray-500">{{ __('السعر') }}</th>
<th class="px-5 py-2.5 text-end font-medium text-gray-500">{{ __('المبلغ') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach($deductionItems as $item)
<tr class="hover:bg-red-50">
<td class="px-5 py-3 text-gray-800">
{{ $item->description }}
@if($item->type)
<span class="ms-2 text-xs text-gray-400">({{ $item->type->label() }})</span>
@endif
</td>
<td class="px-5 py-3 text-end text-gray-600 tabular-nums" dir="ltr">
{{ number_format((float) $item->quantity, 2) }}
</td>
<td class="px-5 py-3 text-end text-gray-600 tabular-nums whitespace-nowrap" dir="ltr">
@if($item->rate > 0)
{{ number_format($item->rate / 100, 2) }} {{ __('ج.م') }}
@else
@endif
</td>
<td class="px-5 py-3 text-end font-semibold text-red-700 tabular-nums whitespace-nowrap" dir="ltr">
-{{ number_format($item->amount / 100, 2) }} {{ __('ج.م') }}
</td>
</tr>
@endforeach
</tbody>
<tfoot class="bg-red-50 border-t-2 border-red-200">
<tr>
<td colspan="3" class="px-5 py-3 text-sm font-semibold text-red-800">{{ __('إجمالي الاستقطاعات') }}</td>
<td class="px-5 py-3 text-end text-sm font-bold text-red-800 tabular-nums whitespace-nowrap" dir="ltr">
-{{ number_format($deductionItems->sum('amount') / 100, 2) }} {{ __('ج.م') }}
</td>
</tr>
</tfoot>
</table>
@else
<div class="px-5 py-10 text-center text-sm text-gray-400">
{{ __('لا توجد استقطاعات في هذا الكشف') }}
</div>
@endif
</div>
{{-- Paid info card (when already paid) --}}
@if($payslip->status->value === 'paid')
<div class="bg-green-50 border border-green-200 rounded-xl p-5 mb-6">
<h3 class="text-sm font-semibold text-green-800 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{{ __('تفاصيل الدفع') }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<p class="text-xs text-green-600">{{ __('طريقة الدفع') }}</p>
<p class="text-sm font-medium text-green-900 mt-0.5">
{{ $payslip->payment_method?->label() ?? '—' }}
</p>
</div>
<div>
<p class="text-xs text-green-600">{{ __('المرجع') }}</p>
<p class="text-sm font-medium text-green-900 mt-0.5" dir="ltr">
{{ $payslip->payment_reference ?? '—' }}
</p>
</div>
<div>
<p class="text-xs text-green-600">{{ __('تاريخ الدفع') }}</p>
<p class="text-sm font-medium text-green-900 mt-0.5" dir="ltr">
{{ $payslip->paid_at ? \Carbon\Carbon::parse($payslip->paid_at)->format('Y-m-d H:i') : '—' }}
</p>
</div>
</div>
</div>
@endif
{{-- Metadata Footer --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{{ __('معلومات إضافية') }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
<div>
<p class="text-xs text-gray-400">{{ __('تاريخ الإنشاء') }}</p>
<p class="text-gray-700 mt-0.5" dir="ltr">{{ $payslip->created_at->format('Y-m-d H:i') }}</p>
</div>
@if($payslip->approver)
<div>
<p class="text-xs text-gray-400">{{ __('اعتمد من قِبَل') }}</p>
<p class="text-gray-700 mt-0.5">{{ $payslip->approver->name }}</p>
@if($payslip->approved_at)
<p class="text-xs text-gray-400 mt-0.5" dir="ltr">{{ \Carbon\Carbon::parse($payslip->approved_at)->format('Y-m-d H:i') }}</p>
@endif
</div>
@endif
@if($payslip->notes)
<div>
<p class="text-xs text-gray-400">{{ __('ملاحظات') }}</p>
<p class="text-gray-700 mt-0.5">{{ $payslip->notes }}</p>
</div>
@endif
</div>
</div>
{{-- Payment Modal --}}
<div x-show="showPaymentModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center"
style="display: none;">
{{-- Backdrop --}}
<div class="absolute inset-0 bg-gray-900/50 backdrop-blur-sm" @click="showPaymentModal = false"></div>
{{-- Modal Panel --}}
<div x-show="showPaymentModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95 translate-y-2"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 translate-y-2"
class="relative w-full max-w-md bg-white rounded-2xl shadow-xl mx-4 overflow-hidden">
{{-- Modal Header --}}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 class="text-base font-semibold text-gray-900">{{ __('تسجيل دفع الراتب') }}</h3>
<button @click="showPaymentModal = false"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{{-- Modal Body --}}
<div class="px-6 py-5 space-y-4">
{{-- Net amount reminder --}}
<div class="bg-blue-50 rounded-lg px-4 py-3 flex items-center justify-between">
<span class="text-sm text-blue-700">{{ __('المبلغ المستحق') }}</span>
<span class="text-lg font-bold text-blue-900 tabular-nums" dir="ltr">
{{ number_format($payslip->net_amount / 100, 2) }} {{ __('ج.م') }}
</span>
</div>
{{-- Payment Method --}}
<div>
<label for="paymentMethod" class="block text-sm font-medium text-gray-700 mb-1">
{{ __('طريقة الدفع') }}
<span class="text-red-500 ms-0.5">*</span>
</label>
<select id="paymentMethod"
wire:model="paymentMethod"
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">
@foreach($paymentMethods as $method)
<option value="{{ $method->value }}">{{ $method->label() }}</option>
@endforeach
</select>
@error('paymentMethod')
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Payment Reference --}}
<div>
<label for="paymentReference" class="block text-sm font-medium text-gray-700 mb-1">
{{ __('رقم المرجع / الإيصال') }}
<span class="text-xs text-gray-400 ms-1">({{ __('اختياري') }})</span>
</label>
<input type="text"
id="paymentReference"
wire:model="paymentReference"
placeholder="{{ __('مثال: TXN-20240105-001') }}"
dir="ltr"
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 placeholder:text-gray-400">
@error('paymentReference')
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
{{-- Modal Footer --}}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
<button @click="showPaymentModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
{{ __('إلغاء') }}
</button>
<button wire:click="processPayment"
wire:loading.attr="disabled"
wire:target="processPayment"
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
<svg wire:loading wire:target="processPayment" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
<span wire:loading.remove wire:target="processPayment">{{ __('تأكيد الدفع') }}</span>
<span wire:loading wire:target="processPayment">{{ __('جارٍ التسجيل...') }}</span>
</button>
</div>
</div>
</div>
</div>
<div>
{{-- Flash messages --}}
@if (session('success'))
<div
x-data="{ show: true }"
x-show="show"
x-init="setTimeout(() => show = false, 4000)"
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-4 text-green-800"
>
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div
x-data="{ show: true }"
x-show="show"
x-init="setTimeout(() => show = false, 6000)"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"
>
{{ session('error') }}
</div>
@endif
{{-- Page header --}}
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ __('السلف والقروض') }}</h1>
<p class="mt-1 text-sm text-gray-500">{{ __('إدارة سلف المدربين وجدول السداد') }}</p>
</div>
@can('payroll.manage')
<button
wire:click="openCreateModal"
class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ __('إضافة سلفة') }}
</button>
@endcan
</div>
{{-- Filters --}}
<div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{-- Search --}}
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0" />
</svg>
</div>
<input
wire:model.live.debounce.300ms="search"
type="text"
placeholder="{{ __('بحث باسم المدرب...') }}"
class="block w-full rounded-lg border border-gray-300 bg-white py-2 pe-10 ps-4 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{{-- Status filter --}}
<select
wire:model.live="statusFilter"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">{{ __('جميع الحالات') }}</option>
@foreach ($statuses as $status)
<option value="{{ $status->value }}">{{ __($status->label()) }}</option>
@endforeach
</select>
</div>
{{-- Table --}}
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div wire:loading.class="opacity-50 pointer-events-none">
@if ($advances->isEmpty())
{{-- Empty state --}}
<div class="flex flex-col items-center justify-center px-6 py-16 text-center">
<svg class="mb-4 h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h3 class="mb-1 text-sm font-semibold text-gray-900">{{ __('لا توجد سلف') }}</h3>
<p class="text-sm text-gray-500">{{ __('لم يتم تسجيل أي سلف حتى الآن') }}</p>
</div>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('المدرب') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('المبلغ الكلي') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('القسط الشهري') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('الأقساط') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('المتبقي') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('تاريخ الإصدار') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('الحالة') }}
</th>
<th class="px-4 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500">
{{ __('نسبة السداد') }}
</th>
<th class="relative px-4 py-3">
<span class="sr-only">{{ __('إجراءات') }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@foreach ($advances as $advance)
@php
$trainerName = optional(optional(optional($advance->trainer)->employee)->person)->name_ar
?? optional(optional($advance->trainer)->person)->name_ar
?? __('—');
$paidInstallments = $advance->paid_installments ?? 0;
$totalInstallments = $advance->installments_count ?? 1;
$paidPercent = $totalInstallments > 0
? min(100, round(($paidInstallments / $totalInstallments) * 100))
: 0;
$remaining = $advance->remaining_amount ?? ($advance->amount - ($advance->deducted_amount ?? 0));
@endphp
<tr class="transition hover:bg-gray-50">
<td class="px-4 py-3">
<span class="text-sm font-medium text-gray-900">{{ $trainerName }}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-700">
{{ number_format($advance->amount / 100, 2) }} {{ __('ج.م') }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-700">
{{ number_format($advance->installment_amount / 100, 2) }} {{ __('ج.م') }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-700">
{{ $paidInstallments }} / {{ $totalInstallments }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm font-medium text-gray-900">
{{ number_format($remaining / 100, 2) }} {{ __('ج.م') }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-500">
{{ \Carbon\Carbon::parse($advance->issued_date)->format('Y/m/d') }}
</span>
</td>
<td class="px-4 py-3">
@php
$statusClasses = match($advance->status) {
'active' => 'bg-green-100 text-green-800',
'fully_deducted' => 'bg-blue-100 text-blue-800',
'cancelled' => 'bg-red-100 text-red-800',
'paused' => 'bg-amber-100 text-amber-800',
default => 'bg-gray-100 text-gray-700',
};
$statusLabel = match($advance->status) {
'active' => __('نشطة'),
'fully_deducted' => __('مسددة بالكامل'),
'cancelled' => __('ملغاة'),
'paused' => __('موقوفة'),
default => $advance->status,
};
@endphp
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $statusClasses }}">
{{ $statusLabel }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="h-2 w-24 overflow-hidden rounded-full bg-gray-200">
<div
class="h-full rounded-full {{ $paidPercent >= 100 ? 'bg-blue-500' : 'bg-indigo-500' }}"
style="width: {{ $paidPercent }}%"
></div>
</div>
<span class="text-xs text-gray-500">{{ $paidPercent }}%</span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
@if ($advance->status === 'active')
<button
wire:click="pauseAdvance({{ $advance->id }})"
wire:confirm="{{ __('هل تريد إيقاف هذه السلفة مؤقتاً؟') }}"
class="rounded px-2 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50 transition"
title="{{ __('إيقاف') }}"
>
{{ __('إيقاف') }}
</button>
<button
wire:click="cancelAdvance({{ $advance->id }})"
wire:confirm="{{ __('هل أنت متأكد من إلغاء هذه السلفة؟') }}"
class="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 transition"
title="{{ __('إلغاء') }}"
>
{{ __('إلغاء') }}
</button>
@elseif ($advance->status === 'paused')
<button
wire:click="resumeAdvance({{ $advance->id }})"
wire:confirm="{{ __('هل تريد استئناف خصم هذه السلفة؟') }}"
class="rounded px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-50 transition"
title="{{ __('استئناف') }}"
>
{{ __('استئناف') }}
</button>
<button
wire:click="cancelAdvance({{ $advance->id }})"
wire:confirm="{{ __('هل أنت متأكد من إلغاء هذه السلفة؟') }}"
class="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 transition"
title="{{ __('إلغاء') }}"
>
{{ __('إلغاء') }}
</button>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Pagination --}}
@if ($advances->hasPages())
<div class="border-t border-gray-200 px-4 py-3">
{{ $advances->links() }}
</div>
@endif
@endif
</div>
</div>
{{-- Create Modal --}}
<div
x-data="{ open: @entangle('showCreateModal') }"
x-show="open"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none;"
>
{{-- Backdrop --}}
<div
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="absolute inset-0 bg-gray-900/50"
></div>
{{-- Modal panel --}}
<div
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="relative z-10 w-full max-w-lg rounded-xl bg-white shadow-2xl"
>
{{-- Modal header --}}
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">{{ __('إضافة سلفة جديدة') }}</h2>
<button
@click="open = false"
class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Modal body --}}
<form wire:submit="createAdvance" class="px-6 py-5 space-y-5">
{{-- Trainer select --}}
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">
{{ __('المدرب') }}
<span class="text-red-500">*</span>
</label>
<select
wire:model="selectedTrainerId"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 @error('selectedTrainerId') border-red-400 @enderror"
>
<option value="">{{ __('اختر مدرباً...') }}</option>
@foreach ($trainers as $trainer)
@php
$name = optional(optional($trainer->employee)->person)->name_ar
?? optional($trainer->person)->name_ar
?? __('مدرب #') . $trainer->id;
@endphp
<option value="{{ $trainer->id }}">{{ $name }}</option>
@endforeach
</select>
@error('selectedTrainerId')
<p class="mt-1 text-xs text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Amount --}}
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">
{{ __('المبلغ (جنيه)') }}
<span class="text-red-500">*</span>
</label>
<div class="relative">
<input
wire:model="amount"
type="number"
min="1"
step="0.01"
dir="ltr"
placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-white py-2 pe-10 ps-3 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 @error('amount') border-red-400 @enderror"
/>
<div class="pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3">
<span class="text-xs text-gray-400">{{ __('ج.م') }}</span>
</div>
</div>
@error('amount')
<p class="mt-1 text-xs text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Installments count --}}
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">
{{ __('عدد الأقساط') }}
<span class="text-red-500">*</span>
</label>
<input
wire:model="installmentsCount"
type="number"
min="1"
max="24"
dir="ltr"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 @error('installmentsCount') border-red-400 @enderror"
/>
@error('installmentsCount')
<p class="mt-1 text-xs text-red-500">{{ $message }}</p>
@enderror
@if ($amount && $installmentsCount > 0)
<p class="mt-1.5 text-xs text-indigo-600">
{{ __('القسط الشهري:') }}
{{ number_format((float) $amount / max(1, (int) $installmentsCount), 2) }}
{{ __('ج.م') }}
</p>
@endif
</div>
{{-- Reason --}}
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">
{{ __('السبب / الملاحظات') }}
</label>
<textarea
wire:model="reason"
rows="3"
placeholder="{{ __('سبب السلفة أو أي ملاحظات إضافية...') }}"
class="block w-full resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
></textarea>
</div>
{{-- Modal footer --}}
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4">
<button
type="button"
@click="open = false"
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
{{ __('إلغاء') }}
</button>
<button
type="submit"
wire:loading.attr="disabled"
wire:target="createAdvance"
class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-60"
>
<span wire:loading.remove wire:target="createAdvance">{{ __('حفظ السلفة') }}</span>
<span wire:loading wire:target="createAdvance" class="flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ __('جارٍ الحفظ...') }}
</span>
</button>
</div>
</form>
</div>
</div>
</div>
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div class="flex items-center gap-3">
<a href="{{ route('trainers.list') }}" wire:navigate
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 text-gray-500 transition-colors">
<svg class="w-5 h-5 rotate-180 rtl:rotate-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-900">
{{ $trainer->employee?->person?->name_ar ?? $trainer->person?->name_ar ?? __('مدرب غير معروف') }}
</h1>
<div class="flex items-center gap-2 mt-1">
<span class="text-sm text-gray-500">{{ __('تعويضات المدرب') }}</span>
@if($trainer->compensation_model)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{{ $trainer->compensation_model->label() }}
</span>
@endif
</div>
</div>
</div>
</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
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
{{ session('error') }}
</div>
@endif
{{-- Summary Cards --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{{-- Total Earnings --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-green-100">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('إجمالي المكاسب') }}</p>
<p class="text-lg font-bold text-green-700">{{ number_format($totalEarnings / 100, 2) }} {{ __('ج.م') }}</p>
</div>
</div>
</div>
{{-- Total Penalties --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-red-100">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('إجمالي الخصومات') }}</p>
<p class="text-lg font-bold text-red-700">{{ number_format($totalPenalties / 100, 2) }} {{ __('ج.م') }}</p>
</div>
</div>
</div>
{{-- Net Amount --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('الصافي') }}</p>
<p class="text-lg font-bold {{ $netAmount >= 0 ? 'text-blue-700' : 'text-red-700' }}">
{{ number_format($netAmount / 100, 2) }} {{ __('ج.م') }}
</p>
</div>
</div>
</div>
{{-- Sessions Count --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-purple-100">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('عدد الحصص') }}</p>
<p class="text-lg font-bold text-purple-700">{{ $sessionsCount }}</p>
</div>
</div>
</div>
</div>
{{-- Date Range + 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">
{{-- Date From --}}
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">{{ __('من تاريخ') }}</label>
<input type="date" wire:model.live="dateFrom" dir="ltr"
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">
</div>
{{-- Date To --}}
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">{{ __('إلى تاريخ') }}</label>
<input type="date" wire:model.live="dateTo" dir="ltr"
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">
</div>
{{-- Type Filter --}}
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">{{ __('النوع') }}</label>
<select wire:model.live="typeFilter"
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">
<option value="">{{ __('كل الأنواع') }}</option>
@foreach($types as $type)
<option value="{{ $type->value }}">{{ $type->label() }}</option>
@endforeach
</select>
</div>
{{-- Status Filter --}}
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">{{ __('الحالة') }}</label>
<select wire:model.live="statusFilter"
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">
<option value="">{{ __('كل الحالات') }}</option>
@foreach($statuses as $status)
<option value="{{ $status->value }}">
@php
$labels = [
'pending' => 'قيد المراجعة',
'approved' => 'معتمد',
'disputed' => 'متنازع عليه',
'paid' => 'مدفوع',
'cancelled'=> 'ملغي',
];
@endphp
{{ $labels[$status->value] ?? $status->value }}
</option>
@endforeach
</select>
</div>
</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-end font-semibold text-gray-600">{{ __('الكمية') }}</th>
<th class="px-4 py-3 text-end font-semibold text-gray-600">{{ __('السعر') }}</th>
<th class="px-4 py-3 text-end 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($compensations as $record)
@php
$isPenalty = ! $record->type->isEarning();
$rowBg = $isPenalty ? 'bg-red-50' : '';
@endphp
<tr class="hover:bg-gray-50 {{ $rowBg }}">
{{-- Date --}}
<td class="px-4 py-3 text-gray-700 whitespace-nowrap" dir="ltr">
{{ $record->date->format('Y-m-d') }}
</td>
{{-- Type badge --}}
<td class="px-4 py-3">
@php
$typeColors = [
'session_pay' => 'bg-blue-100 text-blue-800',
'group_pay' => 'bg-indigo-100 text-indigo-800',
'player_pay' => 'bg-cyan-100 text-cyan-800',
'revenue_share' => 'bg-teal-100 text-teal-800',
'bonus' => 'bg-green-100 text-green-800',
'penalty' => 'bg-red-100 text-red-800',
'overtime' => 'bg-yellow-100 text-yellow-800',
'substitute' => 'bg-purple-100 text-purple-800',
'cancelled_session'=> 'bg-gray-100 text-gray-700',
];
$typeColor = $typeColors[$record->type->value] ?? 'bg-gray-100 text-gray-700';
@endphp
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ $typeColor }}">
{{ $record->type->label() }}
</span>
</td>
{{-- Description --}}
<td class="px-4 py-3 text-gray-700 max-w-xs truncate" title="{{ $record->description }}">
{{ $record->description ?? '—' }}
@if($record->session)
<span class="block text-xs text-gray-400 mt-0.5">
{{ $record->session->name_ar ?? __('حصة') }} — {{ $record->session->scheduled_at?->format('H:i') }}
</span>
@endif
</td>
{{-- Quantity --}}
<td class="px-4 py-3 text-end text-gray-700 tabular-nums" dir="ltr">
{{ number_format((float) $record->quantity, 2) }}
</td>
{{-- Rate --}}
<td class="px-4 py-3 text-end text-gray-700 tabular-nums whitespace-nowrap" dir="ltr">
{{ number_format($record->rate / 100, 2) }} {{ __('ج.م') }}
</td>
{{-- Amount --}}
<td class="px-4 py-3 text-end font-semibold whitespace-nowrap tabular-nums {{ $isPenalty ? 'text-red-700' : 'text-gray-900' }}" dir="ltr">
{{ $isPenalty ? '-' : '' }}{{ number_format($record->amount / 100, 2) }} {{ __('ج.م') }}
</td>
{{-- Status badge --}}
<td class="px-4 py-3">
@php
$statusColors = [
'pending' => 'bg-amber-100 text-amber-800',
'approved' => 'bg-blue-100 text-blue-800',
'disputed' => 'bg-orange-100 text-orange-800',
'paid' => 'bg-green-100 text-green-800',
'cancelled' => 'bg-gray-100 text-gray-600',
];
$statusLabels = [
'pending' => 'قيد المراجعة',
'approved' => 'معتمد',
'disputed' => 'متنازع عليه',
'paid' => 'مدفوع',
'cancelled' => 'ملغي',
];
$statusColor = $statusColors[$record->status->value] ?? 'bg-gray-100 text-gray-600';
$statusLabel = $statusLabels[$record->status->value] ?? $record->status->value;
@endphp
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColor }}">
{{ $statusLabel }}
</span>
</td>
{{-- Actions --}}
<td class="px-4 py-3">
<div class="flex items-center gap-2">
@if($record->status === \App\Domain\HR\Enums\CompensationStatus::Pending)
<button wire:click="approveRecord({{ $record->id }})"
wire:loading.attr="disabled"
wire:target="approveRecord({{ $record->id }})"
class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors disabled:opacity-50">
<span wire:loading.remove wire:target="approveRecord({{ $record->id }})">{{ __('اعتماد') }}</span>
<span wire:loading wire:target="approveRecord({{ $record->id }})">{{ __('جارٍ...') }}</span>
</button>
@endif
@if(in_array($record->status->value, ['pending', 'approved']))
<button wire:click="disputeRecord({{ $record->id }})"
wire:loading.attr="disabled"
wire:target="disputeRecord({{ $record->id }})"
class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-orange-50 text-orange-700 rounded-md hover:bg-orange-100 transition-colors disabled:opacity-50">
<span wire:loading.remove wire:target="disputeRecord({{ $record->id }})">{{ __('اعتراض') }}</span>
<span wire:loading wire:target="disputeRecord({{ $record->id }})">{{ __('جارٍ...') }}</span>
</button>
@endif
@if($record->status === \App\Domain\HR\Enums\CompensationStatus::Approved)
<span class="text-xs text-gray-400">
{{ __('موافق') }}: {{ $record->approved_at?->format('d/m') }}
</span>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-16 text-center">
<div class="flex flex-col items-center gap-3 text-gray-400">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-sm font-medium text-gray-500">{{ __('لا توجد سجلات تعويض') }}</p>
<p class="text-xs text-gray-400">{{ __('لا توجد سجلات تعويض للمدرب في النطاق الزمني المحدد') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
@if($compensations->hasPages())
<div class="px-4 py-3 border-t border-gray-200">
{{ $compensations->links() }}
</div>
@endif
</div>
</div>
......@@ -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