Commit 48dd1184 authored by Mahmoud Aglan's avatar Mahmoud Aglan

koko

parent de3ccd63
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class GenerateRenewalInvoices extends Command
{
protected $signature = 'enrollments:generate-renewals {--dry-run : Show what would be billed without creating invoices}';
protected $description = 'Generate renewal invoices for active enrollments with billing due today or earlier';
public function handle(InvoiceService $invoiceService, PricingService $pricingService): int
{
$dryRun = $this->option('dry-run');
$enrollments = Enrollment::where('status', EnrollmentStatus::Active)
->whereNotNull('next_billing_date')
->where('next_billing_date', '<=', now()->toDateString())
->whereHas('program', function ($q) {
$q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
])
->whereNotNull('billing_cycle');
})
->with(['program', 'participant.person', 'group'])
->get();
if ($enrollments->isEmpty()) {
$this->info('No renewal invoices due today.');
return self::SUCCESS;
}
$this->info("Found {$enrollments->count()} enrollment(s) due for renewal.");
$created = 0;
$failed = 0;
foreach ($enrollments as $enrollment) {
$participant = $enrollment->participant;
$program = $enrollment->program;
$group = $enrollment->group;
if (!$participant || !$program) {
$failed++;
continue;
}
if ($dryRun) {
$this->line(" [DRY] {$participant->person?->name_ar}{$program->name_ar} (due: {$enrollment->next_billing_date->format('Y-m-d')})");
$created++;
continue;
}
try {
$priceResult = $pricingService->calculate(
priceable: $program,
participant: $participant,
branchId: $group?->branch_id ?? $program->branch_id,
);
if ($priceResult->finalAmount <= 0) {
$this->advanceNextBillingDate($enrollment);
continue;
}
$systemUser = User::where('email', 'system@oc-sport.com')->first()
?? User::first();
$invoice = $invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $program->academy_id,
'branch_id' => $group?->branch_id ?? $program->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'number' => $invoiceService->generateNumber($program->academy_id),
'total_amount' => $priceResult->finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
'notes' => 'تجديد تلقائي — ' . $program->name_ar,
], [
[
'description' => "تجديد اشتراك: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
],
], $systemUser);
$this->advanceNextBillingDate($enrollment);
$enrollment->update([
'last_billed_at' => now()->toDateString(),
'payment_status' => 'pending',
]);
$created++;
} catch (DomainException $e) {
Log::warning('Renewal invoice skipped', [
'enrollment_id' => $enrollment->id,
'reason' => $e->getMessage(),
]);
$failed++;
} catch (\Throwable $e) {
Log::error('Renewal invoice failed', [
'enrollment_id' => $enrollment->id,
'error' => $e->getMessage(),
]);
$failed++;
}
}
$label = $dryRun ? 'Would create' : 'Created';
$this->info("{$label} {$created} renewal invoice(s). Failed: {$failed}.");
return self::SUCCESS;
}
private function advanceNextBillingDate(Enrollment $enrollment): void
{
$program = $enrollment->program;
$current = $enrollment->next_billing_date;
$next = match ($program->billing_cycle) {
'monthly' => $this->advanceMonthly($current, $program->billing_day),
'quarterly' => $current->copy()->addMonths(3),
'semi_annual' => $current->copy()->addMonths(6),
'annual' => $current->copy()->addYear(),
'per_duration' => $current->copy()->addWeeks($program->program_duration_weeks ?? 4),
default => $current->copy()->addMonth(),
};
$enrollment->update(['next_billing_date' => $next->toDateString()]);
}
private function advanceMonthly(Carbon $current, ?int $billingDay): Carbon
{
$next = $current->copy()->addMonth();
if ($billingDay) {
$maxDay = $next->daysInMonth;
$next->day = min($billingDay, $maxDay);
}
return $next;
}
}
...@@ -67,7 +67,7 @@ public function employee(): BelongsTo ...@@ -67,7 +67,7 @@ public function employee(): BelongsTo
public function person(): BelongsTo public function person(): BelongsTo
{ {
return $this->belongsTo(\App\Domain\People\Models\Person::class); return $this->belongsTo(\App\Domain\Identity\Models\Person::class);
} }
public function compensations(): HasMany public function compensations(): HasMany
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
use App\Domain\HR\Models\Trainer; use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerCompensation; use App\Domain\HR\Models\TrainerCompensation;
use App\Domain\Shared\Services\SettingsService; use App\Domain\Shared\Services\SettingsService;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
...@@ -52,7 +53,10 @@ public function calculateForSession( ...@@ -52,7 +53,10 @@ public function calculateForSession(
default => CompensationType::SessionPay, default => CompensationType::SessionPay,
}; };
return DB::transaction(function () use ($trainer, $trainingSessionId, $attendanceRecordId, $rate, $type, $actor) { $quantity = $this->resolveSessionQuantity($trainer, $trainingSessionId);
$amount = (int) round($rate * $quantity);
return DB::transaction(function () use ($trainer, $trainingSessionId, $attendanceRecordId, $rate, $quantity, $amount, $type, $actor) {
return TrainerCompensation::create([ return TrainerCompensation::create([
'academy_id' => $trainer->academy_id, 'academy_id' => $trainer->academy_id,
'trainer_id' => $trainer->id, 'trainer_id' => $trainer->id,
...@@ -61,9 +65,9 @@ public function calculateForSession( ...@@ -61,9 +65,9 @@ public function calculateForSession(
'date' => now()->toDateString(), 'date' => now()->toDateString(),
'type' => $type, 'type' => $type,
'description' => $type->label(), 'description' => $type->label(),
'quantity' => 1, 'quantity' => $quantity,
'rate' => $rate, 'rate' => $rate,
'amount' => $rate, // 1 × rate 'amount' => $amount,
'status' => CompensationStatus::Pending, 'status' => CompensationStatus::Pending,
'created_by' => $actor->id, 'created_by' => $actor->id,
]); ]);
...@@ -347,6 +351,41 @@ public function calculateRevenueShare( ...@@ -347,6 +351,41 @@ public function calculateRevenueShare(
}); });
} }
/**
* For hourly trainers, quantity = session duration in hours.
* For all others, quantity = 1 (one session).
*/
private function resolveSessionQuantity(Trainer $trainer, int $trainingSessionId): float
{
if ($trainer->compensation_model->value !== 'hourly') {
return 1;
}
$session = TrainingSession::with('group.program')->find($trainingSessionId);
if (!$session) {
return 1;
}
// Prefer actual times if available, then scheduled times, then program default
$start = $session->actual_start_time ?? $session->start_time;
$end = $session->actual_end_time ?? $session->end_time;
if ($start && $end) {
$minutes = Carbon::parse($start)->diffInMinutes(Carbon::parse($end));
if ($minutes > 0) {
return round($minutes / 60, 2);
}
}
// Fallback to program's session_duration_minutes
$programMinutes = $session->group?->program?->session_duration_minutes;
if ($programMinutes && $programMinutes > 0) {
return round($programMinutes / 60, 2);
}
return 1;
}
/** /**
* Resolve the per-session rate for a trainer based on compensation model. * Resolve the per-session rate for a trainer based on compensation model.
*/ */
......
<?php
namespace App\Domain\HR\Services;
use App\Domain\HR\Models\Trainer;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSchedule;
use Carbon\Carbon;
class TrainerWorkloadService
{
public function getWorkload(int $userId): array
{
return $this->getWorkloadsForUsers([$userId])[$userId] ?? $this->emptyWorkload($userId);
}
public function getWorkloadsForUsers(array $userIds): array
{
if (empty($userIds)) {
return [];
}
$schedules = TrainingSchedule::where('is_active', true)
->where(function ($q) use ($userIds) {
$q->whereIn('trainer_id', $userIds)
->orWhereIn('assistant_trainer_id', $userIds);
})
->select(['id', 'trainer_id', 'assistant_trainer_id', 'training_group_id', 'start_time', 'end_time', 'day_of_week'])
->get();
$headTrainerGroups = TrainingGroup::whereIn('head_trainer_id', $userIds)
->whereIn('status', ['active', 'forming', 'full'])
->with(['schedules' => fn ($q) => $q->where('is_active', true)->whereNull('trainer_id')])
->get();
$trainerCapacities = Trainer::whereHas('employee', fn ($q) => $q->whereIn('user_id', $userIds))
->with('employee:id,user_id')
->get()
->keyBy(fn ($t) => $t->employee->user_id);
$result = [];
foreach ($userIds as $userId) {
$weeklyMinutes = 0;
$sessionsPerDay = [];
$groupIds = collect();
foreach ($schedules as $schedule) {
$isMainTrainer = $schedule->trainer_id === $userId;
$isAssistant = $schedule->assistant_trainer_id === $userId;
if (!$isMainTrainer && !$isAssistant) {
continue;
}
$minutes = $this->minutesBetween($schedule->start_time, $schedule->end_time);
$weeklyMinutes += $minutes;
$sessionsPerDay[$schedule->day_of_week] = ($sessionsPerDay[$schedule->day_of_week] ?? 0) + 1;
$groupIds->push($schedule->training_group_id);
}
foreach ($headTrainerGroups as $group) {
if ($group->head_trainer_id !== $userId) {
continue;
}
foreach ($group->schedules as $schedule) {
$minutes = $this->minutesBetween($schedule->start_time, $schedule->end_time);
$weeklyMinutes += $minutes;
$sessionsPerDay[$schedule->day_of_week] = ($sessionsPerDay[$schedule->day_of_week] ?? 0) + 1;
$groupIds->push($schedule->training_group_id);
}
}
$weeklyHours = round($weeklyMinutes / 60, 1);
$maxDailySessions = !empty($sessionsPerDay) ? max($sessionsPerDay) : 0;
$totalWeeklySessions = array_sum($sessionsPerDay);
$trainer = $trainerCapacities[$userId] ?? null;
$maxWeeklyHours = $trainer?->max_weekly_hours ?? 40;
$maxDailySessionsCap = $trainer?->max_daily_sessions ?? 8;
$loadPercent = $maxWeeklyHours > 0 ? min(100, round(($weeklyHours / $maxWeeklyHours) * 100)) : 0;
$result[$userId] = [
'user_id' => $userId,
'weekly_hours' => $weeklyHours,
'weekly_sessions' => $totalWeeklySessions,
'max_weekly_hours' => (float) $maxWeeklyHours,
'max_daily_sessions' => $maxDailySessionsCap,
'peak_daily_sessions' => $maxDailySessions,
'groups_count' => $groupIds->unique()->count(),
'load_percent' => $loadPercent,
'load_level' => $this->resolveLoadLevel($loadPercent),
];
}
return $result;
}
private function minutesBetween($start, $end): int
{
$s = Carbon::parse($start);
$e = Carbon::parse($end);
return max(0, $s->diffInMinutes($e));
}
private function resolveLoadLevel(int $percent): string
{
if ($percent >= 85) {
return 'high';
}
if ($percent >= 60) {
return 'medium';
}
return 'low';
}
private function emptyWorkload(int $userId): array
{
return [
'user_id' => $userId,
'weekly_hours' => 0,
'weekly_sessions' => 0,
'max_weekly_hours' => 40,
'max_daily_sessions' => 8,
'peak_daily_sessions' => 0,
'groups_count' => 0,
'load_percent' => 0,
'load_level' => 'low',
];
}
}
<?php
namespace App\Domain\Training\Events;
use App\Domain\Training\Models\Enrollment;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class EnrollmentTransferred implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Enrollment $oldEnrollment,
public Enrollment $newEnrollment,
public User $actor,
) {}
}
<?php
namespace App\Domain\Training\Listeners;
use App\Domain\Notification\Services\NotificationService;
use App\Domain\Training\Events\EnrollmentTransferred;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class HandleEnrollmentTransfer implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(EnrollmentTransferred $event): void
{
$oldEnrollment = $event->oldEnrollment;
$newEnrollment = $event->newEnrollment;
$participant = $newEnrollment->participant;
$oldGroup = $oldEnrollment->group;
$newGroup = $newEnrollment->group;
$participantName = $participant?->person?->name_ar ?? '';
$oldGroupName = $oldGroup?->name_ar ?? '';
$newGroupName = $newGroup?->name_ar ?? '';
try {
// Notify guardian
$this->notificationService->sendSimple(
type: 'enrollment_transferred',
recipientId: $participant->id,
recipientType: 'participant',
data: [
'participant_name' => $participantName,
'old_group' => $oldGroupName,
'new_group' => $newGroupName,
'transfer_date' => now()->format('Y-m-d'),
],
);
// Notify old group's trainer
$oldTrainerId = $oldGroup?->head_trainer_id;
if ($oldTrainerId) {
$this->notificationService->sendSimple(
type: 'participant_transferred_out',
recipientId: $oldTrainerId,
recipientType: 'user',
data: [
'participant_name' => $participantName,
'old_group' => $oldGroupName,
'new_group' => $newGroupName,
],
);
}
// Notify new group's trainer
$newTrainerId = $newGroup?->head_trainer_id;
if ($newTrainerId && $newTrainerId !== $oldTrainerId) {
$this->notificationService->sendSimple(
type: 'participant_transferred_in',
recipientId: $newTrainerId,
recipientType: 'user',
data: [
'participant_name' => $participantName,
'old_group' => $oldGroupName,
'new_group' => $newGroupName,
],
);
}
} catch (\Throwable $e) {
Log::error('Transfer notification failed', [
'enrollment_id' => $newEnrollment->id,
'error' => $e->getMessage(),
]);
}
}
}
...@@ -19,7 +19,7 @@ class Enrollment extends Model ...@@ -19,7 +19,7 @@ class Enrollment extends Model
protected $fillable = [ protected $fillable = [
'academy_id', 'participant_id', 'training_group_id', 'training_program_id', 'academy_id', 'participant_id', 'training_group_id', 'training_program_id',
'enrollment_date', 'start_date', 'end_date', 'enrollment_date', 'start_date', 'end_date', 'next_billing_date', 'last_billed_at',
'status', 'enrolled_by', 'invoice_id', 'status', 'enrolled_by', 'invoice_id',
'payment_status', 'sessions_attended', 'sessions_total', 'payment_status', 'sessions_attended', 'sessions_total',
'attendance_percentage', 'completion_percentage', 'attendance_percentage', 'completion_percentage',
...@@ -33,6 +33,8 @@ class Enrollment extends Model ...@@ -33,6 +33,8 @@ class Enrollment extends Model
'enrollment_date' => 'date', 'enrollment_date' => 'date',
'start_date' => 'date', 'start_date' => 'date',
'end_date' => 'date', 'end_date' => 'date',
'next_billing_date' => 'date',
'last_billed_at' => 'date',
'withdrawal_date' => 'date', 'withdrawal_date' => 'date',
'sessions_attended' => 'integer', 'sessions_attended' => 'integer',
'sessions_total' => 'integer', 'sessions_total' => 'integer',
......
...@@ -31,13 +31,14 @@ class TrainingProgram extends Model ...@@ -31,13 +31,14 @@ class TrainingProgram extends Model
'prerequisites', 'facility_type', 'equipment_required', 'prerequisites', 'facility_type', 'equipment_required',
'registration_open', 'registration_deadline', 'registration_open', 'registration_deadline',
'program_start_date', 'program_end_date', 'program_start_date', 'program_end_date',
'renewal_policy', 'cancellation_policy', 'refund_policy', 'renewal_policy', 'billing_cycle', 'billing_day', 'cancellation_policy', 'refund_policy',
'status', 'featured', 'sort_order', 'metadata', 'created_by', 'status', 'featured', 'sort_order', 'metadata', 'created_by',
]; ];
protected $casts = [ protected $casts = [
'status' => ProgramStatus::class, 'status' => ProgramStatus::class,
'renewal_policy' => RenewalPolicy::class, 'renewal_policy' => RenewalPolicy::class,
'billing_day' => 'integer',
'objectives' => 'array', 'objectives' => 'array',
'prerequisites' => 'array', 'prerequisites' => 'array',
'equipment_required' => 'array', 'equipment_required' => 'array',
......
...@@ -7,9 +7,12 @@ ...@@ -7,9 +7,12 @@
use App\Domain\Pricing\Services\PricingService; use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService; use App\Domain\Shared\Services\SettingsService;
use App\Domain\Attendance\Services\AttendanceGenerationService;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Events\EnrollmentCancelled; use App\Domain\Training\Events\EnrollmentCancelled;
use App\Domain\Training\Events\EnrollmentCompleted; use App\Domain\Training\Events\EnrollmentCompleted;
use App\Domain\Training\Events\EnrollmentCreated; use App\Domain\Training\Events\EnrollmentCreated;
use App\Domain\Training\Events\EnrollmentTransferred;
use App\Domain\Training\Events\WaitlistSpotAvailable; use App\Domain\Training\Events\WaitlistSpotAvailable;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup; use App\Domain\Training\Models\TrainingGroup;
...@@ -79,6 +82,7 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -79,6 +82,7 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
'enrollment_date' => now()->toDateString(), 'enrollment_date' => now()->toDateString(),
'start_date' => $options['start_date'] ?? $group->start_date ?? now()->toDateString(), 'start_date' => $options['start_date'] ?? $group->start_date ?? now()->toDateString(),
'end_date' => $options['end_date'] ?? $group->end_date, 'end_date' => $options['end_date'] ?? $group->end_date,
'next_billing_date' => $this->calculateFirstBillingDate($group->program),
'status' => 'active', 'status' => 'active',
'enrolled_by' => $actor->id, 'enrolled_by' => $actor->id,
'invoice_id' => $options['invoice_id'] ?? null, 'invoice_id' => $options['invoice_id'] ?? null,
...@@ -271,7 +275,7 @@ public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $a ...@@ -271,7 +275,7 @@ public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $a
$this->groupService->decrementCount($enrollment->group); $this->groupService->decrementCount($enrollment->group);
// Create new enrollment in target group // Create new enrollment in target group (carry over billing date)
$newEnrollment = Enrollment::create([ $newEnrollment = Enrollment::create([
'participant_id' => $enrollment->participant_id, 'participant_id' => $enrollment->participant_id,
'training_group_id' => $toGroup->id, 'training_group_id' => $toGroup->id,
...@@ -279,6 +283,8 @@ public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $a ...@@ -279,6 +283,8 @@ public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $a
'enrollment_date' => now()->toDateString(), 'enrollment_date' => now()->toDateString(),
'start_date' => now()->toDateString(), 'start_date' => now()->toDateString(),
'end_date' => $toGroup->end_date, 'end_date' => $toGroup->end_date,
'next_billing_date' => $enrollment->next_billing_date?->toDateString(),
'last_billed_at' => $enrollment->last_billed_at?->toDateString(),
'status' => 'active', 'status' => 'active',
'enrolled_by' => $actor->id, 'enrolled_by' => $actor->id,
'payment_status' => $enrollment->payment_status->value, 'payment_status' => $enrollment->payment_status->value,
...@@ -288,9 +294,18 @@ public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $a ...@@ -288,9 +294,18 @@ public function transfer(Enrollment $enrollment, TrainingGroup $toGroup, User $a
$this->groupService->incrementCount($toGroup); $this->groupService->incrementCount($toGroup);
// Remove future attendance from old group
app(AttendanceGenerationService::class)->removeForCancelledEnrollment($enrollment);
// Generate future attendance in new group
app(AttendanceGenerationService::class)->generateForEnrollment($newEnrollment);
// Process waitlist on source group // Process waitlist on source group
$this->processWaitlist($enrollment->group); $this->processWaitlist($enrollment->group);
// Dispatch transfer event (notifications fire after commit)
EnrollmentTransferred::dispatch($enrollment, $newEnrollment, $actor);
return $newEnrollment; return $newEnrollment;
}); });
} }
...@@ -399,6 +414,39 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -399,6 +414,39 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
]); ]);
} }
private function calculateFirstBillingDate(?TrainingProgram $program): ?string
{
if (!$program || !$program->billing_cycle) {
return null;
}
if ($program->renewal_policy === RenewalPolicy::OneTime) {
return null;
}
$start = now();
$next = match ($program->billing_cycle) {
'monthly' => $this->snapToDay($start->copy()->addMonth(), $program->billing_day),
'quarterly' => $start->copy()->addMonths(3),
'semi_annual' => $start->copy()->addMonths(6),
'annual' => $start->copy()->addYear(),
'per_duration' => $start->copy()->addWeeks($program->program_duration_weeks ?? 4),
default => null,
};
return $next?->toDateString();
}
private function snapToDay(\Illuminate\Support\Carbon $date, ?int $billingDay): \Illuminate\Support\Carbon
{
if ($billingDay) {
$date->day = min($billingDay, $date->daysInMonth);
}
return $date;
}
private function getStatusLabel(string $status): string private function getStatusLabel(string $status): string
{ {
return match ($status) { return match ($status) {
......
...@@ -22,6 +22,19 @@ public function participants(Request $request): StreamedResponse ...@@ -22,6 +22,19 @@ public function participants(Request $request): StreamedResponse
$query = Participant::with(['person', 'branch', 'primaryActivity']) $query = Participant::with(['person', 'branch', 'primaryActivity'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($request->status, fn ($q, $s) => $q->where('status', $s)) ->when($request->status, fn ($q, $s) => $q->where('status', $s))
->when($request->search, function ($q) use ($request) {
$search = $request->search;
$q->where(function ($q2) use ($search) {
$q2->where('participant_number', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
});
});
})
->when($request->activity, fn ($q, $a) => $q->where('primary_activity_id', $a))
->when($request->skill_level, fn ($q, $s) => $q->where('skill_level', $s))
->orderByDesc('created_at'); ->orderByDesc('created_at');
return $this->streamCsv('participants', [ return $this->streamCsv('participants', [
......
<?php
namespace App\Livewire\Dashboard;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class RenewalsDueWidget extends Component
{
public function render()
{
$today = now()->toDateString();
$upcoming3Days = now()->addDays(3)->toDateString();
$dueToday = Enrollment::where('status', EnrollmentStatus::Active)
->where('next_billing_date', $today)
->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->count();
$dueThisWeek = Enrollment::where('status', EnrollmentStatus::Active)
->whereBetween('next_billing_date', [$today, $upcoming3Days])
->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->count();
$overdue = Enrollment::where('status', EnrollmentStatus::Active)
->where('next_billing_date', '<', $today)
->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->count();
$byBranch = Enrollment::where('enrollments.status', EnrollmentStatus::Active)
->where('next_billing_date', '<=', $upcoming3Days)
->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->join('training_groups', 'enrollments.training_group_id', '=', 'training_groups.id')
->join('branches', 'training_groups.branch_id', '=', 'branches.id')
->select('branches.name_ar as branch_name', DB::raw('COUNT(*) as count'))
->groupBy('branches.name_ar')
->orderByDesc('count')
->limit(5)
->get();
return view('livewire.dashboard.renewals-due-widget', [
'dueToday' => $dueToday,
'dueThisWeek' => $dueThisWeek,
'overdue' => $overdue,
'byBranch' => $byBranch,
]);
}
}
<?php
namespace App\Livewire\Dashboard;
use App\Domain\HR\Models\Employee;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerCompensation;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class TrainerDuesWidget extends Component
{
public function render()
{
$startOfMonth = now()->startOfMonth()->toDateString();
$endOfMonth = now()->endOfMonth()->toDateString();
$compensations = TrainerCompensation::whereIn('status', ['pending', 'approved'])
->forPeriod($startOfMonth, $endOfMonth)
->select('trainer_id', DB::raw('SUM(amount) as total_amount'), DB::raw('COUNT(*) as records_count'))
->groupBy('trainer_id')
->get()
->keyBy('trainer_id');
$salaryTrainers = Trainer::whereIn('compensation_model', ['salary', 'hybrid'])
->where('status', 'active')
->with('employee:id,salary_amount')
->get();
$trainerTotals = [];
foreach ($compensations as $trainerId => $comp) {
$trainerTotals[$trainerId] = ($trainerTotals[$trainerId] ?? 0) + $comp->total_amount;
}
foreach ($salaryTrainers as $trainer) {
$salary = $trainer->employee?->salary_amount ?? 0;
$trainerTotals[$trainer->id] = ($trainerTotals[$trainer->id] ?? 0) + $salary;
}
$totalDues = array_sum($trainerTotals);
$topTrainers = Trainer::whereIn('id', array_keys($trainerTotals))
->with('employee.person:id,name_ar,name')
->get()
->map(fn ($t) => [
'name' => $t->employee?->person?->name_ar ?? $t->trainer_number,
'amount' => $trainerTotals[$t->id],
])
->sortByDesc('amount')
->take(5)
->values();
return view('livewire.dashboard.trainer-dues-widget', [
'totalDues' => $totalDues,
'trainerCount' => count($trainerTotals),
'topTrainers' => $topTrainers,
'monthName' => now()->translatedFormat('F Y'),
]);
}
}
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
use App\Domain\Facility\Models\SpaceLayout; use App\Domain\Facility\Models\SpaceLayout;
use App\Domain\Facility\Models\SpaceReservation; use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Facility\Services\SpaceCollisionService; use App\Domain\Facility\Services\SpaceCollisionService;
use App\Domain\HR\Services\TrainerWorkloadService;
use App\Domain\Scheduling\Services\ScheduleConflictService; use App\Domain\Scheduling\Services\ScheduleConflictService;
use App\Domain\Training\Models\TrainingGroup; use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSchedule; use App\Domain\Training\Models\TrainingSchedule;
...@@ -224,7 +225,21 @@ public function assignGroupToSegments(int $groupId, array $segmentIds): void ...@@ -224,7 +225,21 @@ public function assignGroupToSegments(int $groupId, array $segmentIds): void
$this->warnings = $validation['warnings']; $this->warnings = $validation['warnings'];
} }
// 4. Add to pending assignments // 4. Sessions-per-week soft warning
$groupForCheck = TrainingGroup::with('program')
->withCount(['schedules as active_schedules_count' => fn ($q) => $q->where('is_active', true)])
->find($groupId);
if ($groupForCheck?->program) {
$target = $groupForCheck->program->sessions_per_week;
$afterAssign = $groupForCheck->active_schedules_count + 1;
if ($afterAssign > $target) {
$this->warnings[] = [
'message' => __('هذه المجموعة تجاوزت عدد الحصص المطلوبة') . " ({$afterAssign}/{$target} " . __('حصص أسبوعياً') . ")",
];
}
}
// 5. Add to pending assignments
$group = TrainingGroup::with('headTrainer')->find($groupId); $group = TrainingGroup::with('headTrainer')->find($groupId);
$this->assignments[] = [ $this->assignments[] = [
'type' => 'group', 'type' => 'group',
...@@ -849,6 +864,7 @@ private function getAvailableGroups(): array ...@@ -849,6 +864,7 @@ private function getAvailableGroups(): array
{ {
return TrainingGroup::whereIn('status', ['active', 'forming', 'full']) return TrainingGroup::whereIn('status', ['active', 'forming', 'full'])
->with(['program', 'headTrainer']) ->with(['program', 'headTrainer'])
->withCount(['schedules as active_schedules_count' => fn ($q) => $q->where('is_active', true)])
->orderBy('name_ar') ->orderBy('name_ar')
->get() ->get()
->map(fn ($g) => [ ->map(fn ($g) => [
...@@ -863,23 +879,29 @@ private function getAvailableGroups(): array ...@@ -863,23 +879,29 @@ private function getAvailableGroups(): array
'color' => $this->groupColor($g->id), 'color' => $this->groupColor($g->id),
'available' => $this->groupAvailability[$g->id]['available'] ?? true, 'available' => $this->groupAvailability[$g->id]['available'] ?? true,
'conflict_reason' => $this->groupAvailability[$g->id]['conflicts'][0]['message'] ?? null, 'conflict_reason' => $this->groupAvailability[$g->id]['conflicts'][0]['message'] ?? null,
'scheduled_sessions' => $g->active_schedules_count,
'sessions_per_week' => $g->program?->sessions_per_week ?? 2,
]) ])
->toArray(); ->toArray();
} }
private function getAvailableTrainers(): array private function getAvailableTrainers(): array
{ {
return User::where('status', 'active') $users = User::where('status', 'active')
->whereHas('primaryRole', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer'])) ->whereHas('primaryRole', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer']))
->orderBy('name_ar') ->orderBy('name_ar')
->get() ->get();
->map(fn ($u) => [
'id' => $u->id, $userIds = $users->pluck('id')->toArray();
'name' => $u->name_ar ?? $u->name, $workloads = app(TrainerWorkloadService::class)->getWorkloadsForUsers($userIds);
'available' => $this->trainerAvailability[$u->id]['available'] ?? true,
'conflict_reason' => $this->trainerAvailability[$u->id]['conflicts'][0]['message'] ?? null, return $users->map(fn ($u) => [
]) 'id' => $u->id,
->toArray(); 'name' => $u->name_ar ?? $u->name,
'available' => $this->trainerAvailability[$u->id]['available'] ?? true,
'conflict_reason' => $this->trainerAvailability[$u->id]['conflicts'][0]['message'] ?? null,
'workload' => $workloads[$u->id] ?? null,
])->toArray();
} }
private function groupColor(int $id): string private function groupColor(int $id): string
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Livewire\Groups; namespace App\Livewire\Groups;
use App\Domain\HR\Models\Trainer; use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\TrainerWorkloadService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\TrainingProgram; use App\Domain\Training\Models\TrainingProgram;
...@@ -166,15 +167,19 @@ public function render() ...@@ -166,15 +167,19 @@ public function render()
->orderBy('name_ar') ->orderBy('name_ar')
->get(['id', 'name_ar', 'name', 'activity_id', 'max_participants']); ->get(['id', 'name_ar', 'name', 'activity_id', 'max_participants']);
$trainers = Trainer::with('employee.person') $trainers = Trainer::with('employee.person', 'employee:id,user_id')
->where('status', 'active') ->where('status', 'active')
->get(); ->get();
$trainerUserIds = $trainers->map(fn ($t) => $t->employee?->user_id)->filter()->values()->toArray();
$workloads = app(TrainerWorkloadService::class)->getWorkloadsForUsers($trainerUserIds);
return view('livewire.groups.create-group-wizard', [ return view('livewire.groups.create-group-wizard', [
'stepLabels' => $this->getStepLabels(), 'stepLabels' => $this->getStepLabels(),
'activities' => Activity::orderBy('name_ar')->get(['id', 'name_ar']), 'activities' => Activity::orderBy('name_ar')->get(['id', 'name_ar']),
'programs' => $programs, 'programs' => $programs,
'trainers' => $trainers, 'trainers' => $trainers,
'trainerWorkloads' => $workloads,
'selectedProgram' => $this->programId ? TrainingProgram::with('activity', 'branch')->find($this->programId) : null, 'selectedProgram' => $this->programId ? TrainingProgram::with('activity', 'branch')->find($this->programId) : null,
]); ]);
} }
......
...@@ -257,9 +257,20 @@ public function rulesForStep(int $step): array ...@@ -257,9 +257,20 @@ public function rulesForStep(int $step): array
'userEmail' => 'required|email|unique:users,email', 'userEmail' => 'required|email|unique:users,email',
'userPassword' => 'required|string|min:8', 'userPassword' => 'required|string|min:8',
] : [], ] : [],
5 => [ 5 => array_merge(
'compensationModel' => 'required|in:' . implode(',', array_column(CompensationModel::cases(), 'value')), ['compensationModel' => 'required|in:' . implode(',', array_column(CompensationModel::cases(), 'value'))],
], match ($this->compensationModel) {
'hourly' => ['hourlyRate' => 'required|numeric|min:1'],
'per_session' => ['sessionRate' => 'required|numeric|min:1'],
'per_group' => ['groupRate' => 'required|numeric|min:1'],
'per_player' => ['playerRate' => 'required|numeric|min:1'],
'revenue_share' => ['revenueSharePercent' => 'required|numeric|min:0.01|max:100'],
'hybrid' => ['sessionRate' => 'required|numeric|min:1'],
'contract' => ['sessionRate' => 'required|numeric|min:1'],
'salary' => [],
default => [],
}
),
6 => [], 6 => [],
7 => [], 7 => [],
8 => $this->assignToGroup ? [ 8 => $this->assignToGroup ? [
...@@ -306,6 +317,22 @@ public function messagesForStep(int $step): array ...@@ -306,6 +317,22 @@ public function messagesForStep(int $step): array
5 => [ 5 => [
'compensationModel.required' => 'نموذج التعويض مطلوب', 'compensationModel.required' => 'نموذج التعويض مطلوب',
'compensationModel.in' => 'نموذج التعويض غير صالح', 'compensationModel.in' => 'نموذج التعويض غير صالح',
'hourlyRate.required' => 'معدل الساعة مطلوب لهذا النموذج',
'hourlyRate.numeric' => 'معدل الساعة يجب أن يكون رقماً',
'hourlyRate.min' => 'معدل الساعة يجب أن يكون أكبر من صفر',
'sessionRate.required' => 'معدل الحصة مطلوب لهذا النموذج',
'sessionRate.numeric' => 'معدل الحصة يجب أن يكون رقماً',
'sessionRate.min' => 'معدل الحصة يجب أن يكون أكبر من صفر',
'groupRate.required' => 'معدل المجموعة مطلوب لهذا النموذج',
'groupRate.numeric' => 'معدل المجموعة يجب أن يكون رقماً',
'groupRate.min' => 'معدل المجموعة يجب أن يكون أكبر من صفر',
'playerRate.required' => 'معدل اللاعب مطلوب لهذا النموذج',
'playerRate.numeric' => 'معدل اللاعب يجب أن يكون رقماً',
'playerRate.min' => 'معدل اللاعب يجب أن يكون أكبر من صفر',
'revenueSharePercent.required' => 'نسبة المشاركة مطلوبة لهذا النموذج',
'revenueSharePercent.numeric' => 'نسبة المشاركة يجب أن تكون رقماً',
'revenueSharePercent.min' => 'نسبة المشاركة يجب أن تكون أكبر من صفر',
'revenueSharePercent.max' => 'نسبة المشاركة لا يمكن أن تتجاوز 100%',
], ],
8 => [ 8 => [
'assignToGroupId.required' => 'يجب اختيار المجموعة', 'assignToGroupId.required' => 'يجب اختيار المجموعة',
......
...@@ -33,6 +33,7 @@ class PayrollDashboard extends Component ...@@ -33,6 +33,7 @@ class PayrollDashboard extends Component
public string $activeTab = 'periods'; // periods | payslips public string $activeTab = 'periods'; // periods | payslips
// Stats // Stats
public bool $payrollEnabled = false;
public int $totalPayrollThisMonth = 0; public int $totalPayrollThisMonth = 0;
public int $pendingApprovals = 0; public int $pendingApprovals = 0;
public int $paidThisMonth = 0; public int $paidThisMonth = 0;
...@@ -69,6 +70,8 @@ public function loadStats(): void ...@@ -69,6 +70,8 @@ public function loadStats(): void
{ {
$academyId = app('current_academy')->id; $academyId = app('current_academy')->id;
$this->payrollEnabled = (bool) app(\App\Domain\Shared\Services\SettingsService::class)->get('payroll_enabled', false);
$currentPeriod = PayrollPeriod::where('academy_id', $academyId) $currentPeriod = PayrollPeriod::where('academy_id', $academyId)
->latest('period_start') ->latest('period_start')
->first(); ->first();
...@@ -93,6 +96,15 @@ public function loadStats(): void ...@@ -93,6 +96,15 @@ public function loadStats(): void
->count(); ->count();
} }
public function enablePayroll(): void
{
app(\App\Domain\Shared\Services\SettingsService::class)->set('payroll_enabled', '1');
app(\App\Domain\Shared\Services\SettingsService::class)->set('auto_trainer_compensation_enabled', '1');
$this->payrollEnabled = true;
session()->flash('success', __('تم تفعيل نظام الرواتب'));
$this->loadStats();
}
public function createPeriod(): void public function createPeriod(): void
{ {
try { try {
...@@ -207,7 +219,7 @@ public function render() ...@@ -207,7 +219,7 @@ public function render()
$periodsQuery->where('status', $this->periodFilter); $periodsQuery->where('status', $this->periodFilter);
} }
$payslipsQuery = Payslip::with(['trainer.employee.person', 'period']) $payslipsQuery = Payslip::with(['trainer.employee.person', 'trainer.person', 'period'])
->where('academy_id', app('current_academy')->id) ->where('academy_id', app('current_academy')->id)
->latest(); ->latest();
......
...@@ -45,7 +45,7 @@ public function updatedCompensationModel(): void ...@@ -45,7 +45,7 @@ public function updatedCompensationModel(): void
public function render() public function render()
{ {
$query = Trainer::with(['employee.person', 'employee.branch']) $query = Trainer::with(['employee.person', 'employee.branch', 'person'])
->latest(); ->latest();
$query = app(PermissionService::class)->applyScope($query, auth()->user(), 'trainers.list'); $query = app(PermissionService::class)->applyScope($query, auth()->user(), 'trainers.list');
...@@ -57,6 +57,10 @@ public function render() ...@@ -57,6 +57,10 @@ public function render()
->orWhereHas('employee.person', function ($pq) use ($search) { ->orWhereHas('employee.person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%") $pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%"); ->orWhere('name', 'ilike', "%{$search}%");
})
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
}); });
}); });
} }
......
<?php
namespace App\Livewire\Participants;
use App\Domain\Participant\Models\Participant;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class BulkStatusChange extends Component
{
public array $selectedIds = [];
public string $targetStatus = '';
public string $reason = '';
public string $currentFilter = 'active';
public bool $confirmModal = false;
private const VALID_TRANSITIONS = [
'active' => ['frozen', 'suspended', 'inactive', 'graduated', 'withdrawn'],
'frozen' => ['active', 'withdrawn', 'inactive'],
'suspended' => ['active', 'withdrawn'],
'inactive' => ['active', 'withdrawn'],
];
public function mount(): void
{
$this->authorize('participants.update');
}
public function getAvailableTargets(): array
{
return self::VALID_TRANSITIONS[$this->currentFilter] ?? [];
}
public function selectAll(): void
{
$this->selectedIds = Participant::where('status', $this->currentFilter)
->pluck('id')
->map(fn ($id) => (string) $id)
->toArray();
}
public function deselectAll(): void
{
$this->selectedIds = [];
}
public function openConfirm(): void
{
if (empty($this->selectedIds) || empty($this->targetStatus)) {
return;
}
$this->confirmModal = true;
}
public function execute(): void
{
$this->validate([
'targetStatus' => 'required',
'reason' => 'required|min:3',
]);
$allowed = self::VALID_TRANSITIONS[$this->currentFilter] ?? [];
if (!in_array($this->targetStatus, $allowed)) {
session()->flash('error', __('انتقال غير مسموح'));
return;
}
$updated = Participant::whereIn('id', $this->selectedIds)
->where('status', $this->currentFilter)
->update(['status' => $this->targetStatus]);
$this->selectedIds = [];
$this->confirmModal = false;
session()->flash('success', __('تم تحديث :count مشترك', ['count' => $updated]));
}
public function render()
{
$participants = Participant::where('status', $this->currentFilter)
->orderBy('created_at', 'desc')
->limit(100)
->get();
return view('livewire.participants.bulk-status-change', [
'participants' => $participants,
'availableTargets' => $this->getAvailableTargets(),
]);
}
}
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
namespace App\Livewire\Programs; namespace App\Livewire\Programs;
use App\Domain\HR\Services\TrainerWorkloadService;
use App\Domain\Identity\Models\Branch; use App\Domain\Identity\Models\Branch;
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
...@@ -46,6 +47,8 @@ class ProgramForm extends Component ...@@ -46,6 +47,8 @@ class ProgramForm extends Component
public ?string $program_start_date = null; public ?string $program_start_date = null;
public ?string $program_end_date = null; public ?string $program_end_date = null;
public string $renewal_policy = 'manual_renew'; public string $renewal_policy = 'manual_renew';
public ?string $billing_cycle = null;
public ?int $billing_day = null;
public string $cancellation_policy = ''; public string $cancellation_policy = '';
public string $refund_policy = ''; public string $refund_policy = '';
public string $status = 'active'; public string $status = 'active';
...@@ -86,6 +89,8 @@ public function mount(?TrainingProgram $program = null): void ...@@ -86,6 +89,8 @@ public function mount(?TrainingProgram $program = null): void
$this->program_start_date = $program->program_start_date?->format('Y-m-d'); $this->program_start_date = $program->program_start_date?->format('Y-m-d');
$this->program_end_date = $program->program_end_date?->format('Y-m-d'); $this->program_end_date = $program->program_end_date?->format('Y-m-d');
$this->renewal_policy = $program->renewal_policy->value ?? $program->renewal_policy; $this->renewal_policy = $program->renewal_policy->value ?? $program->renewal_policy;
$this->billing_cycle = $program->billing_cycle;
$this->billing_day = $program->billing_day;
$this->cancellation_policy = $program->cancellation_policy ?? ''; $this->cancellation_policy = $program->cancellation_policy ?? '';
$this->refund_policy = $program->refund_policy ?? ''; $this->refund_policy = $program->refund_policy ?? '';
$this->status = $program->status->value ?? $program->status; $this->status = $program->status->value ?? $program->status;
...@@ -137,6 +142,8 @@ public function rules(): array ...@@ -137,6 +142,8 @@ public function rules(): array
'program_start_date' => 'nullable|date', 'program_start_date' => 'nullable|date',
'program_end_date' => 'nullable|date|after_or_equal:program_start_date', 'program_end_date' => 'nullable|date|after_or_equal:program_start_date',
'renewal_policy' => 'required|in:auto_renew,manual_renew,one_time', 'renewal_policy' => 'required|in:auto_renew,manual_renew,one_time',
'billing_cycle' => 'nullable|required_unless:renewal_policy,one_time|in:monthly,quarterly,semi_annual,annual,per_duration',
'billing_day' => 'nullable|integer|min:1|max:28',
'cancellation_policy' => 'nullable|string', 'cancellation_policy' => 'nullable|string',
'refund_policy' => 'nullable|string', 'refund_policy' => 'nullable|string',
'featured' => 'boolean', 'featured' => 'boolean',
...@@ -174,6 +181,11 @@ public function messages(): array ...@@ -174,6 +181,11 @@ public function messages(): array
'program_end_date.after_or_equal' => 'تاريخ انتهاء البرنامج يجب أن يكون بعد تاريخ البداية', 'program_end_date.after_or_equal' => 'تاريخ انتهاء البرنامج يجب أن يكون بعد تاريخ البداية',
'renewal_policy.required' => 'سياسة التجديد مطلوبة', 'renewal_policy.required' => 'سياسة التجديد مطلوبة',
'renewal_policy.in' => 'سياسة التجديد غير صالحة', 'renewal_policy.in' => 'سياسة التجديد غير صالحة',
'billing_cycle.required_unless' => 'دورة الفوترة مطلوبة عند تفعيل التجديد',
'billing_cycle.in' => 'دورة الفوترة غير صالحة',
'billing_day.integer' => 'يوم الفوترة يجب أن يكون رقم',
'billing_day.min' => 'يوم الفوترة يجب أن يكون 1 على الأقل',
'billing_day.max' => 'يوم الفوترة يجب ألا يتجاوز 28',
'member_price.numeric' => 'سعر العضو يجب أن يكون رقم', 'member_price.numeric' => 'سعر العضو يجب أن يكون رقم',
'member_price.min' => 'سعر العضو يجب ألا يكون سالب', 'member_price.min' => 'سعر العضو يجب ألا يكون سالب',
'non_member_price.numeric' => 'سعر غير العضو يجب أن يكون رقم', 'non_member_price.numeric' => 'سعر غير العضو يجب أن يكون رقم',
...@@ -214,6 +226,8 @@ public function save(TrainingProgramService $service): void ...@@ -214,6 +226,8 @@ public function save(TrainingProgramService $service): void
'program_start_date' => $this->program_start_date ?: null, 'program_start_date' => $this->program_start_date ?: null,
'program_end_date' => $this->program_end_date ?: null, 'program_end_date' => $this->program_end_date ?: null,
'renewal_policy' => $this->renewal_policy, 'renewal_policy' => $this->renewal_policy,
'billing_cycle' => $this->renewal_policy !== 'one_time' ? $this->billing_cycle : null,
'billing_day' => $this->billing_cycle === 'monthly' ? $this->billing_day : null,
'cancellation_policy' => $this->cancellation_policy ?: null, 'cancellation_policy' => $this->cancellation_policy ?: null,
'refund_policy' => $this->refund_policy ?: null, 'refund_policy' => $this->refund_policy ?: null,
'featured' => $this->featured, 'featured' => $this->featured,
...@@ -278,13 +292,18 @@ private function savePrices(TrainingProgram $program): void ...@@ -278,13 +292,18 @@ private function savePrices(TrainingProgram $program): void
public function render() public function render()
{ {
$trainers = User::whereHas('roles', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer']))
->where('status', 'active')
->orderBy('name')
->get(['id', 'name', 'name_ar']);
$workloads = app(TrainerWorkloadService::class)->getWorkloadsForUsers($trainers->pluck('id')->toArray());
return view('livewire.programs.program-form', [ return view('livewire.programs.program-form', [
'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']), 'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
'branches' => Branch::orderBy('name_ar')->get(['id', 'name_ar']), 'branches' => Branch::orderBy('name_ar')->get(['id', 'name_ar']),
'trainers' => User::whereHas('roles', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer'])) 'trainers' => $trainers,
->where('status', 'active') 'trainerWorkloads' => $workloads,
->orderBy('name')
->get(['id', 'name']),
]); ]);
} }
} }
...@@ -74,6 +74,12 @@ class NewRegistrationWizard extends Component ...@@ -74,6 +74,12 @@ class NewRegistrationWizard extends Component
public bool $pay_now = false; public bool $pay_now = false;
public string $payment_method = 'cash'; public string $payment_method = 'cash';
// Guardian search (existing parent)
public bool $searchExistingGuardian = false;
public string $guardianSearchQuery = '';
public array $guardianSearchResults = [];
public bool $guardianSelected = false;
// Duplicate detection // Duplicate detection
public array $potentialDuplicates = []; public array $potentialDuplicates = [];
public bool $duplicateCheckDone = false; public bool $duplicateCheckDone = false;
...@@ -282,6 +288,62 @@ public function useExistingPerson(int $personId): void ...@@ -282,6 +288,62 @@ public function useExistingPerson(int $personId): void
$this->currentStep = min($this->currentStep + 1, $this->totalSteps); $this->currentStep = min($this->currentStep + 1, $this->totalSteps);
} }
public function updatedGuardianSearchQuery(): void
{
if (strlen($this->guardianSearchQuery) < 2) {
$this->guardianSearchResults = [];
return;
}
$search = $this->guardianSearchQuery;
$this->guardianSearchResults = Guardian::with('person')
->whereHas('person', function ($q) use ($search) {
$q->where('name_ar', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
})
->limit(10)
->get()
->map(fn ($g) => [
'id' => $g->id,
'person_id' => $g->person_id,
'name' => $g->person?->name_ar ?? $g->person?->name,
'phone' => $g->person?->phone,
'relation' => $g->relation_type,
'children_count' => $g->participants()->count(),
])
->toArray();
}
public function selectExistingGuardian(int $guardianId): void
{
$guardian = Guardian::with('person')->find($guardianId);
if (!$guardian) {
return;
}
$this->useExistingPersonId = $guardian->person_id;
$this->guardian_name_ar = $guardian->person->name_ar ?? '';
$this->guardian_name = $guardian->person->name ?? '';
$this->guardian_phone = $guardian->person->phone ?? '';
$this->guardian_national_id = $guardian->person->national_id ?? '';
$this->guardian_relation = $guardian->relation_type ?? 'father';
$this->guardianSelected = true;
$this->guardianSearchResults = [];
}
public function clearSelectedGuardian(): void
{
$this->useExistingPersonId = null;
$this->guardian_name_ar = '';
$this->guardian_name = '';
$this->guardian_phone = '';
$this->guardian_national_id = '';
$this->guardian_relation = 'father';
$this->guardianSelected = false;
$this->searchExistingGuardian = false;
$this->guardianSearchQuery = '';
}
public function previousStep(): void public function previousStep(): void
{ {
$this->currentStep = max($this->currentStep - 1, 1); $this->currentStep = max($this->currentStep - 1, 1);
......
...@@ -57,6 +57,9 @@ class EventServiceProvider extends ServiceProvider ...@@ -57,6 +57,9 @@ class EventServiceProvider extends ServiceProvider
\App\Domain\Training\Events\EnrollmentCompleted::class => [ \App\Domain\Training\Events\EnrollmentCompleted::class => [
\App\Domain\Training\Listeners\SendEnrollmentCompletedNotification::class, \App\Domain\Training\Listeners\SendEnrollmentCompletedNotification::class,
], ],
\App\Domain\Training\Events\EnrollmentTransferred::class => [
\App\Domain\Training\Listeners\HandleEnrollmentTransfer::class,
],
\App\Domain\Training\Events\SessionCreated::class => [ \App\Domain\Training\Events\SessionCreated::class => [
\App\Domain\Training\Listeners\CreateAutoReservation::class, \App\Domain\Training\Listeners\CreateAutoReservation::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('training_programs', function (Blueprint $table) {
$table->string('billing_cycle', 20)->nullable()->after('renewal_policy');
$table->unsignedTinyInteger('billing_day')->nullable()->after('billing_cycle');
});
DB::statement("ALTER TABLE training_programs ADD CONSTRAINT training_programs_billing_cycle_check CHECK (billing_cycle IN ('monthly', 'quarterly', 'semi_annual', 'annual', 'per_duration'))");
Schema::table('enrollments', function (Blueprint $table) {
$table->date('next_billing_date')->nullable()->after('end_date');
$table->date('last_billed_at')->nullable()->after('next_billing_date');
});
$table = 'enrollments';
if (!Schema::hasIndex($table, 'enrollments_next_billing_date_index')) {
Schema::table($table, function (Blueprint $table) {
$table->index('next_billing_date');
});
}
}
public function down(): void
{
DB::statement("ALTER TABLE training_programs DROP CONSTRAINT IF EXISTS training_programs_billing_cycle_check");
Schema::table('training_programs', function (Blueprint $table) {
$table->dropColumn(['billing_cycle', 'billing_day']);
});
Schema::table('enrollments', function (Blueprint $table) {
$table->dropIndex(['next_billing_date']);
$table->dropColumn(['next_billing_date', 'last_billed_at']);
});
}
};
...@@ -163,6 +163,9 @@ private function getPermissionsList(): array ...@@ -163,6 +163,9 @@ private function getPermissionsList(): array
'trainers.show', 'trainers.manage_qualifications', 'trainers.manage_availability', 'trainers.show', 'trainers.manage_qualifications', 'trainers.manage_availability',
'trainers.view_compensation', 'trainers.view_compensation',
// Payroll
'payroll.manage', 'payroll.view', 'payroll.approve', 'payroll.pay',
// Assignments // Assignments
'assignments.list', 'assignments.create', 'assignments.update', 'assignments.cancel', 'assignments.list', 'assignments.create', 'assignments.update', 'assignments.cancel',
......
...@@ -166,6 +166,14 @@ ...@@ -166,6 +166,14 @@
</div> </div>
@endif @endif
<!-- Row 2.5: Financial Widgets -->
@can('payroll.manage')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
<livewire:dashboard.trainer-dues-widget />
<livewire:dashboard.renewals-due-widget />
</div>
@endcan
<!-- Row 3: Two columns — Today's Schedule + Recent Payments --> <!-- Row 3: Two columns — Today's Schedule + Recent Payments -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<!-- Left: Today's Schedule --> <!-- Left: Today's Schedule -->
......
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700">{{ __('التجديدات المستحقة') }}</h3>
<span class="text-xs text-gray-500">{{ now()->translatedFormat('d M') }}</span>
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
{{-- Overdue --}}
<div class="text-center">
<span class="text-2xl font-bold {{ $overdue > 0 ? 'text-red-600' : 'text-gray-400' }}">{{ $overdue }}</span>
<p class="text-xs text-gray-500 mt-1">{{ __('متأخرة') }}</p>
</div>
{{-- Due Today --}}
<div class="text-center">
<span class="text-2xl font-bold {{ $dueToday > 0 ? 'text-amber-600' : 'text-gray-400' }}">{{ $dueToday }}</span>
<p class="text-xs text-gray-500 mt-1">{{ __('اليوم') }}</p>
</div>
{{-- Due Soon --}}
<div class="text-center">
<span class="text-2xl font-bold text-blue-600">{{ $dueThisWeek }}</span>
<p class="text-xs text-gray-500 mt-1">{{ __('خلال ٣ أيام') }}</p>
</div>
</div>
@if($byBranch->isNotEmpty())
<div class="border-t border-gray-100 pt-3 space-y-2">
<p class="text-xs font-medium text-gray-600">{{ __('حسب الفرع') }}</p>
@foreach($byBranch as $branch)
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700">{{ $branch->branch_name }}</span>
<span class="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">
{{ $branch->count }}
</span>
</div>
@endforeach
</div>
@endif
@if($dueToday + $overdue === 0)
<div class="text-center py-2">
<p class="text-sm text-gray-500">{{ __('لا توجد تجديدات مستحقة اليوم') }}</p>
</div>
@endif
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700">{{ __('مستحقات المدربين') }}</h3>
<span class="text-xs text-gray-500">{{ $monthName }}</span>
</div>
<div class="flex items-baseline gap-2 mb-4">
<span class="text-2xl font-bold text-gray-900" dir="ltr">{{ number_format($totalDues / 100, 2) }}</span>
<span class="text-sm text-gray-500">{{ __('ج.م') }}</span>
</div>
<div class="text-xs text-gray-500 mb-4">
{{ $trainerCount }} {{ __('مدرب') }} · {{ __('إجمالي المستحقات هذا الشهر') }}
</div>
@if($topTrainers->isNotEmpty())
<div class="border-t border-gray-100 pt-3 space-y-2.5">
<p class="text-xs font-medium text-gray-600">{{ __('أعلى المستحقات') }}</p>
@foreach($topTrainers as $trainer)
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700">{{ $trainer['name'] }}</span>
<span class="text-sm font-medium text-gray-900" dir="ltr">{{ number_format($trainer['amount'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@endforeach
</div>
@endif
</div>
...@@ -52,6 +52,15 @@ class="border rounded-lg p-2.5 transition-colors ...@@ -52,6 +52,15 @@ class="border rounded-lg p-2.5 transition-colors
<span>{{ $group['program'] }}</span> <span>{{ $group['program'] }}</span>
<span class="text-gray-300">|</span> <span class="text-gray-300">|</span>
<span dir="ltr">{{ $group['count'] }}/{{ $group['max'] }}</span> <span dir="ltr">{{ $group['count'] }}/{{ $group['max'] }}</span>
<span class="text-gray-300">|</span>
@php
$sessionsTarget = $group['sessions_per_week'];
$sessionsCurrent = $group['scheduled_sessions'];
$sessionsColor = $sessionsCurrent >= $sessionsTarget ? 'text-amber-600 bg-amber-50' : 'text-blue-600 bg-blue-50';
@endphp
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium {{ $sessionsColor }}" dir="ltr" title="{{ __('حصص مجدولة / مطلوبة أسبوعياً') }}">
{{ $sessionsCurrent }}/{{ $sessionsTarget }} {{ __('حصص') }}
</span>
</div> </div>
@if($group['trainer']) @if($group['trainer'])
<div class="mt-1 text-xs text-gray-400 truncate"> <div class="mt-1 text-xs text-gray-400 truncate">
...@@ -92,6 +101,25 @@ class="border rounded-lg p-2.5 transition-colors ...@@ -92,6 +101,25 @@ class="border rounded-lg p-2.5 transition-colors
<span class="ms-auto w-2 h-2 rounded-full bg-green-500"></span> <span class="ms-auto w-2 h-2 rounded-full bg-green-500"></span>
@endif @endif
</div> </div>
@if($trainer['workload'])
@php
$wl = $trainer['workload'];
$barColor = match($wl['load_level']) {
'high' => 'bg-red-500',
'medium' => 'bg-amber-500',
default => 'bg-green-500',
};
@endphp
<div class="mt-1.5 ms-8">
<div class="flex items-center justify-between text-[10px] text-gray-500 mb-0.5">
<span dir="ltr">{{ $wl['weekly_hours'] }}/{{ $wl['max_weekly_hours'] }} {{ __('س') }}</span>
<span>{{ $wl['groups_count'] }} {{ __('مجموعات') }}</span>
</div>
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full rounded-full {{ $barColor }}" style="width: {{ $wl['load_percent'] }}%"></div>
</div>
</div>
@endif
@if(!$trainer['available'] && $trainer['conflict_reason']) @if(!$trainer['available'] && $trainer['conflict_reason'])
<p class="mt-1 text-[10px] text-orange-600 truncate ms-8">{{ $trainer['conflict_reason'] }}</p> <p class="mt-1 text-[10px] text-orange-600 truncate ms-8">{{ $trainer['conflict_reason'] }}</p>
@endif @endif
......
...@@ -121,9 +121,32 @@ class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:r ...@@ -121,9 +121,32 @@ class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:r
<select wire:model="headTrainerId" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"> <select wire:model="headTrainerId" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('اختر مدرب...') }}</option> <option value="">{{ __('اختر مدرب...') }}</option>
@foreach($trainers as $trainer) @foreach($trainers as $trainer)
<option value="{{ $trainer->employee?->user_id }}">{{ $trainer->employee?->person?->name_ar ?? $trainer->trainer_number }}</option> @php $wl = $trainerWorkloads[$trainer->employee?->user_id] ?? null; @endphp
<option value="{{ $trainer->employee?->user_id }}">
{{ $trainer->employee?->person?->name_ar ?? $trainer->trainer_number }}
@if($wl) — {{ $wl['weekly_hours'] }}/{{ $wl['max_weekly_hours'] }} {{ __('س') }} ({{ $wl['load_percent'] }}%)@endif
</option>
@endforeach @endforeach
</select> </select>
@if($headTrainerId && isset($trainerWorkloads[$headTrainerId]))
@php $selectedWl = $trainerWorkloads[$headTrainerId]; @endphp
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>{{ __('حمل العمل') }}</span>
<span dir="ltr">{{ $selectedWl['weekly_hours'] }}/{{ $selectedWl['max_weekly_hours'] }} {{ __('ساعة') }}</span>
</div>
@php
$barColor = match($selectedWl['load_level']) {
'high' => 'bg-red-500',
'medium' => 'bg-amber-500',
default => 'bg-green-500',
};
@endphp
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full rounded-full {{ $barColor }}" style="width: {{ $selectedWl['load_percent'] }}%"></div>
</div>
</div>
@endif
</div> </div>
</div> </div>
@endif @endif
......
...@@ -313,56 +313,76 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"> ...@@ -313,56 +313,76 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
@if(in_array($compensationModel, ['hourly', 'hybrid'])) @if(in_array($compensationModel, ['hourly', 'hybrid']))
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سعر الساعة') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('سعر الساعة') }}
@if($compensationModel === 'hourly') <span class="text-red-500">*</span> @endif
</label>
<div class="relative"> <div class="relative">
<input type="number" wire:model="hourlyRate" dir="ltr" step="0.01" min="0" <input type="number" wire:model="hourlyRate" dir="ltr" step="0.01" min="0"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12 @error('hourlyRate') border-red-500 @enderror">
<span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span> <span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span>
</div> </div>
@error('hourlyRate') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
@endif @endif
@if(in_array($compensationModel, ['per_session', 'hybrid'])) @if(in_array($compensationModel, ['per_session', 'hybrid', 'contract']))
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سعر الحصة') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('سعر الحصة') }}
@if(in_array($compensationModel, ['per_session', 'hybrid', 'contract'])) <span class="text-red-500">*</span> @endif
</label>
<div class="relative"> <div class="relative">
<input type="number" wire:model="sessionRate" dir="ltr" step="0.01" min="0" <input type="number" wire:model="sessionRate" dir="ltr" step="0.01" min="0"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12 @error('sessionRate') border-red-500 @enderror">
<span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span> <span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span>
</div> </div>
@error('sessionRate') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
@endif @endif
@if(in_array($compensationModel, ['per_group', 'hybrid'])) @if(in_array($compensationModel, ['per_group', 'hybrid']))
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سعر المجموعة') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('سعر المجموعة') }}
@if($compensationModel === 'per_group') <span class="text-red-500">*</span> @endif
</label>
<div class="relative"> <div class="relative">
<input type="number" wire:model="groupRate" dir="ltr" step="0.01" min="0" <input type="number" wire:model="groupRate" dir="ltr" step="0.01" min="0"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12 @error('groupRate') border-red-500 @enderror">
<span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span> <span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span>
</div> </div>
@error('groupRate') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
@endif @endif
@if(in_array($compensationModel, ['per_player', 'hybrid'])) @if(in_array($compensationModel, ['per_player', 'hybrid']))
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سعر اللاعب') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('سعر اللاعب') }}
@if($compensationModel === 'per_player') <span class="text-red-500">*</span> @endif
</label>
<div class="relative"> <div class="relative">
<input type="number" wire:model="playerRate" dir="ltr" step="0.01" min="0" <input type="number" wire:model="playerRate" dir="ltr" step="0.01" min="0"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-12 @error('playerRate') border-red-500 @enderror">
<span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span> <span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">{{ __('ج.م') }}</span>
</div> </div>
@error('playerRate') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
@endif @endif
@if(in_array($compensationModel, ['revenue_share', 'hybrid'])) @if(in_array($compensationModel, ['revenue_share', 'hybrid']))
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نسبة الإيرادات') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('نسبة الإيرادات') }}
@if($compensationModel === 'revenue_share') <span class="text-red-500">*</span> @endif
</label>
<div class="relative"> <div class="relative">
<input type="number" wire:model="revenueSharePercent" dir="ltr" step="0.1" min="0" max="100" <input type="number" wire:model="revenueSharePercent" dir="ltr" step="0.1" min="0" max="100"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-8"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm pe-8 @error('revenueSharePercent') border-red-500 @enderror">
<span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">%</span> <span class="absolute end-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">%</span>
</div> </div>
@error('revenueSharePercent') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
@endif @endif
</div> </div>
......
...@@ -64,7 +64,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f ...@@ -64,7 +64,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($employees as $emp) @forelse($employees as $emp)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $emp->person->name_ar ?? '-' }}</td> <td class="px-4 py-3 font-medium text-gray-900">{{ $emp->person?->name_ar ?? '-' }}</td>
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{{ $emp->employee_number }}</td> <td class="px-4 py-3 text-gray-600 font-mono text-xs">{{ $emp->employee_number }}</td>
<td class="px-4 py-3 text-gray-600">{{ $emp->position ?? '-' }}</td> <td class="px-4 py-3 text-gray-600">{{ $emp->position ?? '-' }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
......
...@@ -48,6 +48,28 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white text-sm ...@@ -48,6 +48,28 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white text-sm
</div> </div>
@endif @endif
{{-- ─── Payroll Disabled Warning ──────────────────────────────────────── --}}
@if(!$payrollEnabled)
<div class="mb-6 p-6 bg-amber-50 border border-amber-200 rounded-xl">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-base font-semibold text-amber-900">{{ __('نظام الرواتب غير مفعل') }}</h3>
<p class="text-sm text-amber-700 mt-1">{{ __('يجب تفعيل نظام الرواتب من إعدادات النظام أو بالضغط على الزر أدناه. بعد التفعيل سيتم احتساب أجور المدربين تلقائياً عند تسجيل الحضور.') }}</p>
<button wire:click="enablePayroll" wire:loading.attr="disabled" wire:target="enablePayroll"
class="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white text-sm font-medium rounded-lg hover:bg-amber-700 disabled:opacity-60 transition-colors">
<span wire:loading.remove wire:target="enablePayroll">{{ __('تفعيل نظام الرواتب') }}</span>
<span wire:loading wire:target="enablePayroll">{{ __('جارٍ التفعيل...') }}</span>
</button>
</div>
</div>
</div>
@endif
{{-- ─── Stats Row ────────────────────────────────────────────────────── --}} {{-- ─── Stats Row ────────────────────────────────────────────────────── --}}
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
...@@ -335,10 +357,10 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f ...@@ -335,10 +357,10 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<p class="font-medium text-gray-900"> <p class="font-medium text-gray-900">
{{ $payslip->trainer->employee->person->name_ar ?? '-' }} {{ $payslip->trainer?->employee?->person?->name_ar ?? $payslip->trainer?->person?->name_ar ?? '-' }}
</p> </p>
<p class="text-xs text-gray-400 mt-0.5"> <p class="text-xs text-gray-400 mt-0.5">
{{ $payslip->trainer->employee->branch?->name_ar ?? '' }} {{ $payslip->trainer?->employee?->branch?->name_ar ?? '' }}
</p> </p>
</td> </td>
<td class="px-4 py-3 text-gray-600 text-xs"> <td class="px-4 py-3 text-gray-600 text-xs">
......
...@@ -124,12 +124,12 @@ class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ...@@ -124,12 +124,12 @@ class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm
?? optional(optional($advance->trainer)->person)->name_ar ?? optional(optional($advance->trainer)->person)->name_ar
?? __('—'); ?? __('—');
$paidInstallments = $advance->paid_installments ?? 0; $paidInstallments = $advance->installments_paid ?? 0;
$totalInstallments = $advance->installments_count ?? 1; $totalInstallments = $advance->installments_count ?? 1;
$paidPercent = $totalInstallments > 0 $paidPercent = $totalInstallments > 0
? min(100, round(($paidInstallments / $totalInstallments) * 100)) ? min(100, round(($paidInstallments / $totalInstallments) * 100))
: 0; : 0;
$remaining = $advance->remaining_amount ?? ($advance->amount - ($advance->deducted_amount ?? 0)); $remaining = $advance->remaining_balance;
@endphp @endphp
<tr class="transition hover:bg-gray-50"> <tr class="transition hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
...@@ -162,20 +162,19 @@ class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ...@@ -162,20 +162,19 @@ class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@php @php
$statusClasses = match($advance->status) { $statusValue = $advance->status instanceof \App\Domain\HR\Enums\AdvanceStatus
? $advance->status->value
: (string) $advance->status;
$statusClasses = match($statusValue) {
'active' => 'bg-green-100 text-green-800', 'active' => 'bg-green-100 text-green-800',
'fully_deducted' => 'bg-blue-100 text-blue-800', 'fully_deducted' => 'bg-blue-100 text-blue-800',
'cancelled' => 'bg-red-100 text-red-800', 'cancelled' => 'bg-red-100 text-red-800',
'paused' => 'bg-amber-100 text-amber-800', 'paused' => 'bg-amber-100 text-amber-800',
default => 'bg-gray-100 text-gray-700', default => 'bg-gray-100 text-gray-700',
}; };
$statusLabel = match($advance->status) { $statusLabel = $advance->status instanceof \App\Domain\HR\Enums\AdvanceStatus
'active' => __('نشطة'), ? $advance->status->label()
'fully_deducted' => __('مسددة بالكامل'), : $statusValue;
'cancelled' => __('ملغاة'),
'paused' => __('موقوفة'),
default => $advance->status,
};
@endphp @endphp
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $statusClasses }}"> <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $statusClasses }}">
{{ $statusLabel }} {{ $statusLabel }}
...@@ -194,7 +193,7 @@ class="h-full rounded-full {{ $paidPercent >= 100 ? 'bg-blue-500' : 'bg-indigo-5 ...@@ -194,7 +193,7 @@ class="h-full rounded-full {{ $paidPercent >= 100 ? 'bg-blue-500' : 'bg-indigo-5
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if ($advance->status === 'active') @if ($statusValue === 'active')
<button <button
wire:click="pauseAdvance({{ $advance->id }})" wire:click="pauseAdvance({{ $advance->id }})"
wire:confirm="{{ __('هل تريد إيقاف هذه السلفة مؤقتاً؟') }}" wire:confirm="{{ __('هل تريد إيقاف هذه السلفة مؤقتاً؟') }}"
...@@ -211,7 +210,7 @@ class="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 transi ...@@ -211,7 +210,7 @@ class="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 transi
> >
{{ __('إلغاء') }} {{ __('إلغاء') }}
</button> </button>
@elseif ($advance->status === 'paused') @elseif ($statusValue === 'paused')
<button <button
wire:click="resumeAdvance({{ $advance->id }})" wire:click="resumeAdvance({{ $advance->id }})"
wire:confirm="{{ __('هل تريد استئناف خصم هذه السلفة؟') }}" wire:confirm="{{ __('هل تريد استئناف خصم هذه السلفة؟') }}"
......
...@@ -58,8 +58,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f ...@@ -58,8 +58,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
@forelse($trainers as $trainer) @forelse($trainers as $trainer)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<p class="font-medium text-gray-900">{{ $trainer->employee->person->name_ar ?? '-' }}</p> <p class="font-medium text-gray-900">{{ $trainer->employee?->person?->name_ar ?? $trainer->person?->name_ar ?? '-' }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ $trainer->employee->branch?->name_ar ?? '' }}</p> <p class="text-xs text-gray-500 mt-0.5">{{ $trainer->employee?->branch?->name_ar ?? '' }}</p>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@if(!empty($trainer->sports)) @if(!empty($trainer->sports))
......
<div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('تغيير حالة جماعي') }}</h1>
<a href="{{ route('participants.list') }}" wire:navigate class="text-sm text-blue-600 hover:text-blue-800">{{ __('العودة') }}</a>
</div>
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 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-700 text-sm">{{ session('error') }}</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4 items-end">
<div>
<label class="block text-sm text-gray-600 mb-1">{{ __('الحالة الحالية') }}</label>
<select wire:model.live="currentFilter" class="w-full rounded-lg border-gray-300 text-sm py-2.5">
<option value="active">{{ __('active') }}</option>
<option value="frozen">{{ __('frozen') }}</option>
<option value="suspended">{{ __('suspended') }}</option>
<option value="inactive">{{ __('inactive') }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">{{ __('التغيير إلى') }}</label>
<select wire:model="targetStatus" class="w-full rounded-lg border-gray-300 text-sm py-2.5">
<option value="">{{ __('اختر') }}</option>
@foreach($availableTargets as $target)
<option value="{{ $target }}">{{ __($target) }}</option>
@endforeach
</select>
</div>
<div class="flex gap-2 col-span-2 md:col-span-1">
<button wire:click="selectAll" class="flex-1 md:flex-none px-3 py-2.5 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
{{ __('تحديد الكل') }}
</button>
<button wire:click="deselectAll" class="flex-1 md:flex-none px-3 py-2.5 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
{{ __('إلغاء التحديد') }}
</button>
</div>
<div class="col-span-2 md:col-span-3 lg:col-span-2">
<button wire:click="openConfirm" @disabled(empty($selectedIds) || empty($targetStatus))
class="w-full sm:w-auto px-4 py-2.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
{{ __('تنفيذ') }} ({{ count($selectedIds) }})
</button>
</div>
</div>
</div>
{{-- Desktop Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500 w-10"></th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('الاسم') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500">{{ __('تاريخ التسجيل') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($participants as $p)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<input type="checkbox" wire:model="selectedIds" value="{{ $p->id }}"
class="w-4 h-4 text-blue-600 rounded border-gray-300">
</td>
<td class="px-4 py-3 text-sm text-gray-800">{{ $p->full_name }}</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">{{ __($p->status) }}</span>
</td>
<td class="px-4 py-3 text-sm text-gray-500" dir="ltr">{{ $p->created_at?->format('Y-m-d') }}</td>
</tr>
@empty
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-gray-500">{{ __('لا يوجد مشتركين') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Mobile Cards --}}
<div class="md:hidden space-y-3">
@forelse($participants as $p)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<input type="checkbox" wire:model="selectedIds" value="{{ $p->id }}"
class="w-4 h-4 text-blue-600 rounded border-gray-300 shrink-0">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ $p->full_name }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">{{ __($p->status) }}</span>
<span class="text-xs text-gray-500" dir="ltr">{{ $p->created_at?->format('Y-m-d') }}</span>
</div>
</div>
</div>
</div>
@empty
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center text-sm text-gray-500">
{{ __('لا يوجد مشتركين') }}
</div>
@endforelse
</div>
@if($confirmModal)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-4 sm:p-6">
<h3 class="text-base sm:text-lg font-bold text-gray-800 mb-2">{{ __('تأكيد التغيير') }}</h3>
<p class="text-sm text-gray-600 mb-4">
{{ __('سيتم تغيير حالة :count مشترك من :from إلى :to', [
'count' => count($selectedIds),
'from' => __($currentFilter),
'to' => __($targetStatus),
]) }}
</p>
<div class="mb-4">
<label class="block text-sm text-gray-600 mb-1">{{ __('السبب') }} *</label>
<textarea wire:model="reason" rows="2" class="w-full rounded-lg border-gray-300 text-sm py-2.5"></textarea>
@error('reason') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col-reverse sm:flex-row gap-3 sm:justify-end">
<button wire:click="$set('confirmModal', false)" class="w-full sm:w-auto px-4 py-2.5 text-sm text-gray-600 hover:text-gray-800">{{ __('إلغاء') }}</button>
<button wire:click="execute" class="w-full sm:w-auto px-4 py-2.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700">{{ __('تأكيد') }}</button>
</div>
</div>
</div>
@endif
</div>
...@@ -3,8 +3,7 @@ ...@@ -3,8 +3,7 @@
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('المشتركين') }}</h1> <h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('المشتركين') }}</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="{{ route('participants.bulk-status') }}" wire:navigate class="inline-flex items-center gap-1.5 px-3 py-2 min-h-[44px] text-sm font-medium text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors">{{ __('تغيير جماعي') }}</a> <a href="{{ route('export.participants', ['status' => $status, 'search' => $search, 'activity' => $activity, 'skill_level' => $skillLevel]) }}"
<a href="{{ route('export.participants', ['status' => $status ?? '']) }}"
class="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs sm:text-sm font-medium transition-colors"> class="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs sm:text-sm font-medium 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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> <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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
<span class="hidden sm:inline">{{ __('تصدير CSV') }}</span> <span class="hidden sm:inline">{{ __('تصدير CSV') }}</span>
......
...@@ -48,14 +48,38 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق ...@@ -48,14 +48,38 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('المدرب الافتراضي') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('المدرب الافتراضي') }}</label>
<select wire:model="default_trainer_id" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> <select wire:model.live="default_trainer_id" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('بدون مدرب افتراضي') }}</option> <option value="">{{ __('بدون مدرب افتراضي') }}</option>
@foreach($trainers as $trainer) @foreach($trainers as $trainer)
<option value="{{ $trainer->id }}">{{ $trainer->name }}</option> @php $wl = $trainerWorkloads[$trainer->id] ?? null; @endphp
<option value="{{ $trainer->id }}">
{{ $trainer->name_ar ?? $trainer->name }}
@if($wl) — {{ $wl['weekly_hours'] }}/{{ $wl['max_weekly_hours'] }} {{ __('س') }}@endif
</option>
@endforeach @endforeach
</select> </select>
@error('default_trainer_id') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror @error('default_trainer_id') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
<p class="text-xs text-gray-500 mt-1">{{ __('يُعيّن تلقائياً للمجموعات الجديدة') }}</p> @if($default_trainer_id && isset($trainerWorkloads[$default_trainer_id]))
@php $selectedWl = $trainerWorkloads[$default_trainer_id]; @endphp
@php
$barColor = match($selectedWl['load_level']) {
'high' => 'bg-red-500',
'medium' => 'bg-amber-500',
default => 'bg-green-500',
};
@endphp
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500 mb-0.5">
<span>{{ __('حمل العمل') }}</span>
<span dir="ltr">{{ $selectedWl['weekly_hours'] }}/{{ $selectedWl['max_weekly_hours'] }} {{ __('ساعة') }} · {{ $selectedWl['groups_count'] }} {{ __('مجموعات') }}</span>
</div>
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full rounded-full {{ $barColor }}" style="width: {{ $selectedWl['load_percent'] }}%"></div>
</div>
</div>
@else
<p class="text-xs text-gray-500 mt-1">{{ __('يُعيّن تلقائياً للمجموعات الجديدة') }}</p>
@endif
</div> </div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الوصف بالعربية') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الوصف بالعربية') }}</label>
...@@ -88,12 +112,34 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق ...@@ -88,12 +112,34 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سياسة التجديد') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سياسة التجديد') }}</label>
<select wire:model="renewal_policy" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> <select wire:model.live="renewal_policy" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="manual_renew">{{ __('تجديد يدوي') }}</option> <option value="manual_renew">{{ __('تجديد يدوي') }}</option>
<option value="auto_renew">{{ __('تجديد تلقائي') }}</option> <option value="auto_renew">{{ __('تجديد تلقائي') }}</option>
<option value="one_time">{{ __('مرة واحدة') }}</option> <option value="one_time">{{ __('مرة واحدة') }}</option>
</select> </select>
</div> </div>
@if($renewal_policy !== 'one_time')
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('دورة الفوترة') }} <span class="text-red-500">*</span></label>
<select wire:model.live="billing_cycle" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('اختر...') }}</option>
<option value="monthly">{{ __('شهري') }}</option>
<option value="quarterly">{{ __('ربع سنوي') }}</option>
<option value="semi_annual">{{ __('نصف سنوي') }}</option>
<option value="annual">{{ __('سنوي') }}</option>
<option value="per_duration">{{ __('حسب مدة البرنامج') }}</option>
</select>
@error('billing_cycle') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
@if($billing_cycle === 'monthly')
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('يوم الفوترة') }}</label>
<input type="number" wire:model="billing_day" min="1" max="28" dir="ltr" placeholder="1-28" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ __('اليوم من الشهر لإصدار الفاتورة (1-28)') }}</p>
@error('billing_day') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
@endif
@endif
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('العمر الأدنى') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('العمر الأدنى') }}</label>
<input type="number" wire:model="age_min" min="1" max="99" dir="ltr" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> <input type="number" wire:model="age_min" min="1" max="99" dir="ltr" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
......
...@@ -116,8 +116,71 @@ class="flex items-center gap-2 {{ $num < $currentStep ? 'cursor-pointer' : 'curs ...@@ -116,8 +116,71 @@ class="flex items-center gap-2 {{ $num < $currentStep ? 'cursor-pointer' : 'curs
{{-- Step 1: Guardian Info --}} {{-- Step 1: Guardian Info --}}
@if($currentStep === 1) @if($currentStep === 1)
<div> <div>
<h2 class="text-lg font-semibold text-gray-800 mb-6">{{ __('بيانات ولي الأمر') }}</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('بيانات ولي الأمر') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
{{-- Toggle: search existing or new --}}
@if(!$guardianSelected)
<div class="flex gap-3 mb-6">
<button wire:click="$set('searchExistingGuardian', false)" type="button"
class="flex-1 px-4 py-3 text-sm font-medium rounded-lg border transition-colors {{ !$searchExistingGuardian ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-300 text-gray-600 hover:border-gray-400' }}">
{{ __('تسجيل ولي أمر جديد') }}
</button>
<button wire:click="$set('searchExistingGuardian', true)" type="button"
class="flex-1 px-4 py-3 text-sm font-medium rounded-lg border transition-colors {{ $searchExistingGuardian ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-300 text-gray-600 hover:border-gray-400' }}">
{{ __('بحث عن ولي أمر مسجل') }}
</button>
</div>
@endif
{{-- Search existing guardian --}}
@if($searchExistingGuardian && !$guardianSelected)
<div class="mb-6">
<input type="text" wire:model.live.debounce.300ms="guardianSearchQuery"
placeholder="{{ __('ابحث بالاسم أو رقم الهاتف...') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg">
@if(!empty($guardianSearchResults))
<div class="mt-3 space-y-2 max-h-60 overflow-y-auto">
@foreach($guardianSearchResults as $result)
<button wire:click="selectExistingGuardian({{ $result['id'] }})" type="button"
class="w-full flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors text-start">
<div>
<p class="font-medium text-gray-800">{{ $result['name'] }}</p>
<div class="flex items-center gap-3 text-sm text-gray-500 mt-0.5">
<span dir="ltr">{{ $result['phone'] }}</span>
@if($result['children_count'] > 0)
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{{ $result['children_count'] }} {{ __('أبناء مسجلين') }}</span>
@endif
</div>
</div>
<svg class="w-5 h-5 text-blue-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
@endforeach
</div>
@elseif(strlen($guardianSearchQuery) >= 2)
<p class="mt-3 text-sm text-gray-500 text-center py-4">{{ __('لا توجد نتائج') }}</p>
@endif
</div>
@endif
{{-- Selected guardian display --}}
@if($guardianSelected)
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-green-800">{{ $guardian_name_ar }}</p>
<p class="text-sm text-green-600" dir="ltr">{{ $guardian_phone }}</p>
</div>
<button wire:click="clearSelectedGuardian" type="button" class="text-sm text-green-700 hover:text-green-900 font-medium underline">
{{ __('تغيير') }}
</button>
</div>
</div>
@endif
{{-- Guardian form (new or show data) --}}
@if(!$searchExistingGuardian || $guardianSelected)
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5 {{ $guardianSelected ? 'opacity-60 pointer-events-none' : '' }}">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }} <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model="guardian_name_ar" <input type="text" wire:model="guardian_name_ar"
...@@ -166,6 +229,7 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 ...@@ -166,6 +229,7 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2
@error('guardian_relation') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('guardian_relation') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
</div> </div>
@endif
{{-- Duplicate Detection Warning --}} {{-- Duplicate Detection Warning --}}
@if(!empty($potentialDuplicates)) @if(!empty($potentialDuplicates))
......
...@@ -23,4 +23,5 @@ ...@@ -23,4 +23,5 @@
Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30'); Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30');
Schedule::command('financials:reconcile')->weeklyOn(0, '04:00'); Schedule::command('financials:reconcile')->weeklyOn(0, '04:00');
Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00'); Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00');
Schedule::command('enrollments:generate-renewals')->dailyAt('07:00');
Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00'); Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00');
...@@ -489,9 +489,6 @@ ...@@ -489,9 +489,6 @@
Route::get('/invoices/{invoice}/print', \App\Http\Controllers\InvoicePrintController::class)->name('invoices.print') Route::get('/invoices/{invoice}/print', \App\Http\Controllers\InvoicePrintController::class)->name('invoices.print')
->middleware('permission:invoices.view'); ->middleware('permission:invoices.view');
// Participants — Bulk Actions
Route::get('/participants/bulk-status', \App\Livewire\Participants\BulkStatusChange::class)->name('participants.bulk-status')
->middleware('permission:participants.update');
// Certificates // Certificates
Route::get('/participants/{participant}/certificate/{enrollment}', [\App\Http\Controllers\CertificateController::class, 'attendance']) Route::get('/participants/{participant}/certificate/{enrollment}', [\App\Http\Controllers\CertificateController::class, 'attendance'])
......
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