Commit b4c9f796 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add waitlist management, group transfer, quick attendance, import, profile, and more

New features:
- Waitlist manager with promote/cancel actions
- Group transfer (move participant between groups)
- Quick attendance marking (mobile-friendly trainer view)
- Participant CSV import with preview and validation
- User profile page with password change
- Daily financial report (printable)
- Reconcile group counts command
- Deactivate expired enrollments command
- Overdue invoice reminders (2x weekly)
- Sidebar navigation updates (waitlist, transfer, quick attendance, import)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 54175068
<?php
namespace App\Console\Commands;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Console\Command;
class DeactivateExpiredEnrollments extends Command
{
protected $signature = 'enrollments:deactivate-expired';
protected $description = 'Set active enrollments past their expiry date to expired status';
public function handle(): int
{
$expired = Enrollment::where('status', 'active')
->whereNotNull('expiry_date')
->where('expiry_date', '<', now()->toDateString())
->get();
$count = 0;
foreach ($expired as $enrollment) {
$enrollment->update(['status' => 'expired']);
$count++;
}
$this->info("Deactivated {$count} expired enrollments.");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use Illuminate\Console\Command;
class ReconcileGroupCounts extends Command
{
protected $signature = 'groups:reconcile-counts';
protected $description = 'Reconcile group current_count with actual active enrollments';
public function handle(): int
{
$groups = TrainingGroup::whereIn('status', ['active', 'full', 'forming'])->get();
$fixed = 0;
foreach ($groups as $group) {
$actualCount = Enrollment::where('training_group_id', $group->id)
->where('status', 'active')
->count();
if ($group->current_count !== $actualCount) {
$this->warn("{$group->name_ar}: {$group->current_count}{$actualCount}");
$group->update(['current_count' => $actualCount]);
if ($actualCount >= $group->max_capacity && $group->status === 'active') {
$group->update(['status' => 'full']);
} elseif ($actualCount < $group->max_capacity && $group->status === 'full') {
$group->update(['status' => 'active']);
}
$fixed++;
}
}
$this->info("Reconciled {$fixed} groups out of {$groups->count()} checked.");
return self::SUCCESS;
}
}
<?php
namespace App\Http\Controllers;
use App\Domain\Financial\Models\Payment;
use App\Domain\POS\Models\POSTransaction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportPrintController extends Controller
{
public function dailyFinancial(Request $request)
{
Gate::authorize('reports.view');
$date = $request->get('date', now()->toDateString());
$branchId = session('active_branch_id');
$payments = Payment::where('status', 'confirmed')
->whereDate('payment_date', $date)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with(['createdBy', 'invoice'])
->orderBy('payment_date')
->get();
$posTransactions = POSTransaction::whereDate('processed_at', $date)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('user')
->orderBy('processed_at')
->get();
$totalPayments = $payments->sum('amount');
$totalPOS = $posTransactions->sum('total_amount');
$grandTotal = $totalPayments + $totalPOS;
$methodBreakdown = $payments->groupBy(fn ($p) => $p->method?->value ?? 'other')
->map(fn ($group) => [
'count' => $group->count(),
'total' => $group->sum('amount'),
]);
return view('reports.daily-financial-print', [
'date' => $date,
'payments' => $payments,
'posTransactions' => $posTransactions,
'totalPayments' => $totalPayments,
'totalPOS' => $totalPOS,
'grandTotal' => $grandTotal,
'methodBreakdown' => $methodBreakdown,
]);
}
}
<?php
namespace App\Livewire\Attendance;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Attendance\Services\AttendanceMarkingService;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('حضور سريع')]
class QuickAttendance extends Component
{
use UsesBranchScope;
public ?int $selectedSessionId = null;
/** @var array<int, string> record_id => status */
public array $marks = [];
public bool $saved = false;
public function mount(): void
{
$this->authorize('attendance.mark');
}
public function selectSession(int $sessionId): void
{
$this->selectedSessionId = $sessionId;
$this->saved = false;
$this->loadMarks();
}
public function loadMarks(): void
{
if (!$this->selectedSessionId) {
$this->marks = [];
return;
}
$records = AttendanceRecord::where('training_session_id', $this->selectedSessionId)
->where('subject_type', \App\Domain\Participant\Models\Participant::class)
->get();
$this->marks = [];
foreach ($records as $record) {
// Default: if still 'expected', mark as 'present'; otherwise keep current status
$this->marks[$record->id] = $record->status === AttendanceStatus::Expected
? AttendanceStatus::Present->value
: $record->status->value;
}
}
public function toggleAbsent(int $recordId): void
{
if (!isset($this->marks[$recordId])) {
return;
}
$current = $this->marks[$recordId];
$this->marks[$recordId] = $current === AttendanceStatus::Present->value
? AttendanceStatus::Absent->value
: AttendanceStatus::Present->value;
$this->saved = false;
}
public function markAllPresent(): void
{
foreach ($this->marks as $recordId => $status) {
$this->marks[$recordId] = AttendanceStatus::Present->value;
}
$this->saved = false;
}
public function save(AttendanceMarkingService $service): void
{
if (!$this->selectedSessionId) {
return;
}
$user = auth()->user();
$marksPayload = [];
foreach ($this->marks as $recordId => $status) {
$marksPayload[] = [
'record_id' => $recordId,
'status' => $status,
];
}
$service->bulkMark($this->selectedSessionId, $marksPayload, $user);
$this->saved = true;
session()->flash('success', __('تم حفظ الحضور بنجاح'));
}
public function render()
{
$user = auth()->user();
$today = now()->toDateString();
// Get group IDs assigned to this trainer
$assignedGroupIds = Assignment::active()
->forUser($user->id)
->where('assignable_type', TrainingGroup::class)
->pluck('assignable_id');
// Today's sessions for assigned groups
$todaySessions = TrainingSession::whereIn('training_group_id', $assignedGroupIds)
->where('session_date', $today)
->whereIn('status', [SessionStatus::Scheduled, SessionStatus::InProgress, SessionStatus::Completed])
->with('group')
->orderBy('start_time')
->get();
// Load attendance records for selected session
$records = collect();
$summary = ['total' => 0, 'present' => 0, 'absent' => 0];
if ($this->selectedSessionId) {
$records = AttendanceRecord::where('training_session_id', $this->selectedSessionId)
->where('subject_type', \App\Domain\Participant\Models\Participant::class)
->with('subject.person')
->orderBy('id')
->get();
$summary['total'] = count($this->marks);
$summary['present'] = collect($this->marks)->filter(
fn ($s) => $s === AttendanceStatus::Present->value
)->count();
$summary['absent'] = $summary['total'] - $summary['present'];
}
return view('livewire.attendance.quick-attendance', [
'todaySessions' => $todaySessions,
'records' => $records,
'summary' => $summary,
]);
}
}
<?php
namespace App\Livewire\Enrollments;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\EnrollmentService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('نقل إلى مجموعة')]
class TransferGroup extends Component
{
use UsesBranchScope;
public string $search = '';
public ?int $participant_id = null;
public ?int $enrollment_id = null;
public ?int $destination_group_id = null;
public function mount(): void
{
$this->authorize('enrollments.create');
}
public function rules(): array
{
return [
'participant_id' => 'required|exists:participants,id',
'enrollment_id' => 'required|exists:enrollments,id',
'destination_group_id' => 'required|exists:training_groups,id',
];
}
public function messages(): array
{
return [
'participant_id.required' => 'اختيار المشترك مطلوب',
'participant_id.exists' => 'المشترك المختار غير موجود',
'enrollment_id.required' => 'اختيار التسجيل الحالي مطلوب',
'enrollment_id.exists' => 'التسجيل المختار غير موجود',
'destination_group_id.required' => 'اختيار المجموعة الجديدة مطلوب',
'destination_group_id.exists' => 'المجموعة المختارة غير موجودة',
];
}
public function updatedSearch(): void
{
$this->participant_id = null;
$this->enrollment_id = null;
$this->destination_group_id = null;
}
public function selectParticipant(int $id): void
{
$this->participant_id = $id;
$this->enrollment_id = null;
$this->destination_group_id = null;
}
public function selectEnrollment(int $id): void
{
$this->enrollment_id = $id;
$this->destination_group_id = null;
}
public function transfer(EnrollmentService $service): void
{
$this->validate();
try {
$enrollment = Enrollment::findOrFail($this->enrollment_id);
$toGroup = TrainingGroup::findOrFail($this->destination_group_id);
$service->transfer($enrollment, $toGroup, auth()->user());
session()->flash('success', __('تم نقل المشترك بنجاح إلى المجموعة الجديدة'));
$this->redirect(route('enrollments.list'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$branchId = $this->getActiveBranchId();
// Search participants
$participants = collect();
if (strlen($this->search) >= 2) {
$participants = Participant::with('person')
->whereIn('status', ['registered', 'active'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where(function ($q) {
$q->whereHas('person', function ($pq) {
$pq->where('name_ar', 'ilike', "%{$this->search}%")
->orWhere('name', 'ilike', "%{$this->search}%");
})->orWhere('participant_number', 'ilike', "%{$this->search}%");
})
->limit(20)
->get();
}
// Active enrollments for selected participant
$activeEnrollments = collect();
if ($this->participant_id) {
$activeEnrollments = Enrollment::with(['group', 'program'])
->where('participant_id', $this->participant_id)
->where('status', 'active')
->get();
}
// Available destination groups (excluding current group)
$destinationGroups = collect();
if ($this->enrollment_id) {
$enrollment = Enrollment::find($this->enrollment_id);
if ($enrollment) {
$destinationGroups = TrainingGroup::with('program')
->whereIn('status', ['forming', 'active'])
->where('id', '!=', $enrollment->training_group_id)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereColumn('current_count', '<', 'max_capacity')
->orderBy('name_ar')
->get();
}
}
return view('livewire.enrollments.transfer-group', [
'participants' => $participants,
'activeEnrollments' => $activeEnrollments,
'destinationGroups' => $destinationGroups,
]);
}
}
<?php
namespace App\Livewire\Enrollments;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Events\WaitlistSpotAvailable;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('قائمة الانتظار')]
class WaitlistManager extends Component
{
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
#[Url]
public string $groupFilter = '';
#[Url]
public string $programFilter = '';
public function mount(): void
{
$this->authorize('enrollments.list');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedGroupFilter(): void
{
$this->resetPage();
}
public function updatedProgramFilter(): void
{
$this->resetPage();
}
public function promote(int $enrollmentId): void
{
$this->authorize('enrollments.update');
$enrollment = Enrollment::where('status', EnrollmentStatus::Waitlisted)->findOrFail($enrollmentId);
$group = $enrollment->group;
if ($group && $group->isFull()) {
session()->flash('error', __('المجموعة ممتلئة، لا يمكن الترقية'));
return;
}
$enrollment->update(['status' => 'pending']);
// Update group counts
if ($group) {
$group->decrement('waitlist_count');
}
// Dispatch event for notification
WaitlistSpotAvailable::dispatch($group, $enrollment);
session()->flash('success', __('تم ترقية المشترك من قائمة الانتظار بنجاح'));
}
public function cancel(int $enrollmentId, string $reason): void
{
$this->authorize('enrollments.cancel');
$enrollment = Enrollment::where('status', EnrollmentStatus::Waitlisted)->findOrFail($enrollmentId);
$enrollment->update([
'status' => 'cancelled',
'withdrawal_reason' => $reason,
'withdrawal_date' => now(),
]);
// Update group waitlist count
if ($enrollment->group) {
$enrollment->group->decrement('waitlist_count');
}
session()->flash('success', __('تم إلغاء التسجيل من قائمة الانتظار'));
}
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Enrollment::query()
->where('status', EnrollmentStatus::Waitlisted)
->with(['participant.person', 'group', 'program'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($gq) => $gq->where('branch_id', $branchId)))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
$q2->whereHas('participant.person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
})->orWhereHas('participant', function ($pq) use ($search) {
$pq->where('participant_number', 'ilike', "%{$search}%");
});
});
})
->when($this->groupFilter, fn ($q) => $q->where('training_group_id', $this->groupFilter))
->when($this->programFilter, fn ($q) => $q->where('training_program_id', $this->programFilter))
->orderBy('enrollment_date');
return view('livewire.enrollments.waitlist-manager', [
'enrollments' => $query->paginate(20),
'groups' => TrainingGroup::whereIn('status', ['forming', 'active', 'full'])
->orderBy('name_ar')
->get(['id', 'name_ar', 'current_count', 'max_capacity']),
'programs' => TrainingProgram::where('status', 'active')
->orderBy('name_ar')
->get(['id', 'name_ar']),
]);
}
}
<?php
namespace App\Livewire\Participants;
use App\Domain\Identity\Models\Person;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
#[Title('استيراد مشتركين')]
class ParticipantImport extends Component
{
use WithFileUploads, UsesBranchScope;
public $file;
public array $preview = [];
public array $errors = [];
public int $imported = 0;
public int $skipped = 0;
public bool $processing = false;
public bool $done = false;
public function mount(): void
{
$this->authorize('participants.create');
}
public function rules(): array
{
return [
'file' => 'required|file|mimes:csv,txt|max:5120',
];
}
public function updatedFile(): void
{
$this->validate();
$this->preview = [];
$this->errors = [];
$path = $this->file->getRealPath();
$handle = fopen($path, 'r');
$header = fgetcsv($handle);
if (!$header) {
$this->errors[] = 'الملف فارغ أو غير صالح';
fclose($handle);
return;
}
$header = array_map('trim', $header);
$required = ['name_ar', 'phone'];
$missing = array_diff($required, $header);
if (!empty($missing)) {
$this->errors[] = 'أعمدة مفقودة: ' . implode(', ', $missing);
fclose($handle);
return;
}
$rows = 0;
while (($row = fgetcsv($handle)) !== false && $rows < 5) {
$data = array_combine($header, $row);
$this->preview[] = $data;
$rows++;
}
fclose($handle);
}
public function import(): void
{
$this->validate();
$this->processing = true;
$this->imported = 0;
$this->skipped = 0;
$this->errors = [];
$branchId = $this->getActiveBranchIdOrFail();
$path = $this->file->getRealPath();
$handle = fopen($path, 'r');
$header = array_map('trim', fgetcsv($handle));
$lineNum = 1;
DB::beginTransaction();
try {
while (($row = fgetcsv($handle)) !== false) {
$lineNum++;
if (count($row) !== count($header)) {
$this->errors[] = "سطر {$lineNum}: عدد الأعمدة غير متطابق";
$this->skipped++;
continue;
}
$data = array_combine($header, array_map('trim', $row));
$validator = Validator::make($data, [
'name_ar' => 'required|string|max:255',
'phone' => 'required|string|max:20',
]);
if ($validator->fails()) {
$this->errors[] = "سطر {$lineNum}: " . implode(', ', $validator->errors()->all());
$this->skipped++;
continue;
}
$person = Person::firstOrCreate(
['phone' => $data['phone']],
[
'name_ar' => $data['name_ar'],
'name' => $data['name'] ?? null,
'email' => $data['email'] ?? null,
'gender' => $data['gender'] ?? null,
'date_of_birth' => $data['date_of_birth'] ?? null,
'nationality' => $data['nationality'] ?? 'EG',
]
);
$existingParticipant = Participant::where('person_id', $person->id)
->where('branch_id', $branchId)
->first();
if ($existingParticipant) {
$this->skipped++;
continue;
}
Participant::create([
'person_id' => $person->id,
'branch_id' => $branchId,
'academy_id' => app('current_academy')->id,
'status' => 'registered',
'membership_type' => $data['membership_type'] ?? 'non_member',
'membership_id' => $data['membership_id'] ?? null,
'created_by' => auth()->id(),
]);
$this->imported++;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
$this->errors[] = 'خطأ: ' . $e->getMessage();
}
fclose($handle);
$this->processing = false;
$this->done = true;
}
public function render()
{
return view('livewire.participants.participant-import');
}
}
<?php
namespace App\Livewire\Profile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('الملف الشخصي')]
class UserProfile extends Component
{
public string $name = '';
public string $name_ar = '';
public string $email = '';
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
public function mount(): void
{
$user = auth()->user();
$this->name = $user->name ?? '';
$this->name_ar = $user->name_ar ?? '';
$this->email = $user->email ?? '';
}
public function updateProfile(): void
{
$this->validate([
'name' => 'nullable|string|max:255',
'name_ar' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,' . auth()->id(),
], [
'name_ar.required' => 'الاسم بالعربية مطلوب',
'email.required' => 'البريد الإلكتروني مطلوب',
'email.email' => 'صيغة البريد غير صحيحة',
'email.unique' => 'البريد مستخدم بالفعل',
]);
auth()->user()->update([
'name' => $this->name,
'name_ar' => $this->name_ar,
'email' => $this->email,
]);
session()->flash('success', __('تم تحديث الملف الشخصي بنجاح'));
}
public function changePassword(): void
{
$this->validate([
'current_password' => 'required',
'new_password' => ['required', 'confirmed', Password::min(8)],
], [
'current_password.required' => 'كلمة المرور الحالية مطلوبة',
'new_password.required' => 'كلمة المرور الجديدة مطلوبة',
'new_password.confirmed' => 'تأكيد كلمة المرور غير مطابق',
'new_password.min' => 'كلمة المرور يجب أن تكون 8 أحرف على الأقل',
]);
if (!Hash::check($this->current_password, auth()->user()->password)) {
$this->addError('current_password', 'كلمة المرور الحالية غير صحيحة');
return;
}
auth()->user()->update([
'password' => Hash::make($this->new_password),
]);
$this->reset(['current_password', 'new_password', 'new_password_confirmation']);
session()->flash('password_success', __('تم تغيير كلمة المرور بنجاح'));
}
public function render()
{
return view('livewire.profile.user-profile');
}
}
......@@ -5,6 +5,7 @@
['section' => 'المشاركين', 'items' => [
['label' => 'المشاركين', 'route' => 'participants.list', 'icon' => 'users', 'permission' => 'participants.list'],
['label' => 'استيراد مشتركين', 'route' => 'participants.import', 'icon' => 'document-text', 'permission' => 'participants.create'],
['label' => 'الأشخاص', 'route' => 'people.list', 'icon' => 'user-group', 'permission' => 'participants.list'],
]],
......@@ -13,12 +14,15 @@
['label' => 'البرامج', 'route' => 'programs.list', 'icon' => 'academic-cap', 'permission' => 'programs.list'],
['label' => 'المجموعات', 'route' => 'groups.list', 'icon' => 'user-group', 'permission' => 'groups.list'],
['label' => 'التسجيلات', 'route' => 'enrollments.list', 'icon' => 'clipboard-check', 'permission' => 'enrollments.list'],
['label' => 'قائمة الانتظار', 'route' => 'enrollments.waitlist', 'icon' => 'clock', 'permission' => 'enrollments.list'],
['label' => 'نقل مشترك', 'route' => 'enrollments.transfer', 'icon' => 'truck', 'permission' => 'enrollments.create'],
['label' => 'التقييمات', 'route' => 'evaluations.list', 'icon' => 'chart-bar', 'permission' => 'evaluations.list'],
['label' => 'التعيينات', 'route' => 'assignments.list', 'icon' => 'calendar', 'permission' => 'assignments.list'],
]],
['section' => 'الحضور والجدول', 'items' => [
['label' => 'تسجيل الحضور', 'route' => 'attendance.list', 'icon' => 'clipboard-check', 'permission' => 'attendance.mark'],
['label' => 'حضور سريع', 'route' => 'attendance.quick', 'icon' => 'check-badge', 'permission' => 'attendance.mark'],
['label' => 'الجدول الأسبوعي', 'route' => 'schedule.weekly', 'icon' => 'calendar-days', 'permission' => 'schedules.view'],
['label' => 'لوحة المدرب', 'route' => 'trainer.dashboard', 'icon' => 'user', 'permission' => 'attendance.mark'],
]],
......
This diff is collapsed.
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ __('نقل إلى مجموعة') }}</h1>
<a href="{{ route('enrollments.list') }}" wire:navigate
class="text-sm text-gray-600 hover:text-gray-800">
{{ __('العودة للقائمة') }}
</a>
</div>
{{-- Flash Messages --}}
@if(session('success'))
<div class="mb-4 p-4 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-4 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-6 space-y-6">
{{-- Step 1: Search Participant --}}
<div>
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">
{{ __('البحث عن مشترك') }} <span class="text-red-500">*</span>
</label>
<input type="text"
wire:model.live.debounce.300ms="search"
id="search"
placeholder="{{ __('ابحث بالاسم أو رقم المشترك...') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{{-- Search Results --}}
@if(strlen($search) >= 2 && $participants->isNotEmpty() && !$participant_id)
<div class="mt-2 border border-gray-200 rounded-lg divide-y divide-gray-100 max-h-60 overflow-y-auto">
@foreach($participants as $participant)
<button type="button"
wire:click="selectParticipant({{ $participant->id }})"
class="w-full px-4 py-3 text-start hover:bg-blue-50 flex items-center justify-between">
<div>
<span class="text-sm font-medium text-gray-800">{{ $participant->person?->name_ar }}</span>
@if($participant->participant_number)
<span class="text-xs text-gray-500 ms-2">#{{ $participant->participant_number }}</span>
@endif
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
{{ $participant->status->value === 'active' ? 'نشط' : 'مسجل' }}
</span>
</button>
@endforeach
</div>
@elseif(strlen($search) >= 2 && $participants->isEmpty() && !$participant_id)
<p class="mt-2 text-sm text-gray-500">{{ __('لا توجد نتائج') }}</p>
@endif
</div>
{{-- Selected Participant Info --}}
@if($participant_id)
@php
$selectedParticipant = $participants->firstWhere('id', $participant_id)
?? \App\Domain\Participant\Models\Participant::with('person')->find($participant_id);
@endphp
@if($selectedParticipant)
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<div>
<span class="text-sm font-medium text-blue-800">{{ __('المشترك المختار:') }}</span>
<span class="text-sm text-blue-700 ms-1">{{ $selectedParticipant->person?->name_ar }}</span>
@if($selectedParticipant->participant_number)
<span class="text-xs text-blue-600 ms-2">#{{ $selectedParticipant->participant_number }}</span>
@endif
</div>
<button type="button" wire:click="$set('participant_id', null)" class="text-blue-600 hover:text-blue-800 text-xs">
{{ __('تغيير') }}
</button>
</div>
@endif
{{-- Step 2: Select Active Enrollment --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{{ __('التسجيلات النشطة') }} <span class="text-red-500">*</span>
</label>
@if($activeEnrollments->isEmpty())
<p class="text-sm text-gray-500 p-3 bg-gray-50 rounded-lg">{{ __('لا توجد تسجيلات نشطة لهذا المشترك') }}</p>
@else
<div class="space-y-2">
@foreach($activeEnrollments as $enrollment)
<label class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition
{{ $enrollment_id === $enrollment->id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300' }}">
<input type="radio"
wire:click="selectEnrollment({{ $enrollment->id }})"
name="enrollment_selection"
value="{{ $enrollment->id }}"
@checked($enrollment_id === $enrollment->id)
class="text-blue-600 focus:ring-blue-500">
<div class="flex-1">
<span class="text-sm font-medium text-gray-800">{{ $enrollment->group?->name_ar }}</span>
@if($enrollment->program)
<span class="text-xs text-gray-500 ms-2">— {{ $enrollment->program->name_ar }}</span>
@endif
<div class="text-xs text-gray-500 mt-0.5">
{{ __('تاريخ التسجيل:') }} {{ $enrollment->enrollment_date?->format('Y-m-d') }}
@if($enrollment->group)
<span class="ms-3">{{ __('السعة:') }} {{ $enrollment->group->current_count }}/{{ $enrollment->group->max_capacity }}</span>
@endif
</div>
</div>
</label>
@endforeach
</div>
@endif
@error('enrollment_id')
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Step 3: Select Destination Group --}}
@if($enrollment_id)
<div>
<label for="destination_group_id" class="block text-sm font-medium text-gray-700 mb-1">
{{ __('المجموعة الجديدة') }} <span class="text-red-500">*</span>
</label>
@if($destinationGroups->isEmpty())
<p class="text-sm text-gray-500 p-3 bg-gray-50 rounded-lg">{{ __('لا توجد مجموعات متاحة بها أماكن شاغرة') }}</p>
@else
<select wire:model="destination_group_id" id="destination_group_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('destination_group_id') border-red-500 @enderror">
<option value="">{{ __('اختر المجموعة الجديدة...') }}</option>
@foreach($destinationGroups as $group)
<option value="{{ $group->id }}">
{{ $group->name_ar }}
@if($group->program)
— {{ $group->program->name_ar }}
@endif
({{ $group->current_count }}/{{ $group->max_capacity }})
</option>
@endforeach
</select>
@endif
@error('destination_group_id')
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
@endif
{{-- Submit --}}
@if($enrollment_id && $destinationGroups->isNotEmpty())
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<a href="{{ route('enrollments.list') }}" wire:navigate
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg">
{{ __('إلغاء') }}
</a>
<button type="button"
wire:click="transfer"
wire:loading.attr="disabled"
wire:target="transfer"
wire:confirm="{{ __('هل أنت متأكد من نقل المشترك إلى المجموعة الجديدة؟') }}"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
<span wire:loading.remove wire:target="transfer">{{ __('نقل') }}</span>
<span wire:loading wire:target="transfer">{{ __('جارٍ النقل...') }}</span>
</button>
</div>
@endif
@endif {{-- end participant_id --}}
</div>
</div>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ __('استيراد مشتركين من CSV') }}</h1>
<a href="{{ route('participants.list') }}" wire:navigate
class="text-sm text-blue-600 hover:text-blue-800">{{ __('العودة للقائمة') }}</a>
</div>
{{-- Instructions --}}
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
<h3 class="text-sm font-semibold text-blue-800 mb-2">{{ __('تعليمات الاستيراد') }}</h3>
<ul class="text-sm text-blue-700 space-y-1 list-disc list-inside">
<li>{{ __('الملف يجب أن يكون بصيغة CSV') }}</li>
<li>{{ __('الأعمدة المطلوبة: name_ar, phone') }}</li>
<li>{{ __('أعمدة اختيارية: name, email, gender, date_of_birth, nationality, membership_type, membership_id') }}</li>
<li>{{ __('إذا كان رقم الهاتف موجود مسبقاً، يتم تخطي السجل') }}</li>
</ul>
</div>
{{-- Upload --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('ملف CSV') }}</label>
<input type="file" wire:model="file" accept=".csv,.txt"
class="block w-full text-sm text-gray-500 file:me-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
@error('file') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div wire:loading wire:target="file" class="text-sm text-gray-500">{{ __('جارٍ تحميل الملف...') }}</div>
</div>
{{-- Preview --}}
@if(!empty($preview))
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h3 class="text-sm font-semibold text-gray-800 mb-3">{{ __('معاينة (أول 5 سجلات)') }}</h3>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
@foreach(array_keys($preview[0]) as $col)
<th class="px-3 py-2 text-start text-xs font-medium text-gray-500">{{ $col }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach($preview as $row)
<tr class="border-t">
@foreach($row as $cell)
<td class="px-3 py-2 text-gray-700">{{ $cell }}</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
<button wire:click="import" wire:loading.attr="disabled"
class="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
<span wire:loading.remove wire:target="import">{{ __('بدء الاستيراد') }}</span>
<span wire:loading wire:target="import">{{ __('جارٍ الاستيراد...') }}</span>
</button>
</div>
@endif
{{-- Errors --}}
@if(!empty($errors))
<div class="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<h3 class="text-sm font-semibold text-red-800 mb-2">{{ __('أخطاء') }}</h3>
<ul class="text-sm text-red-700 space-y-1 max-h-40 overflow-y-auto">
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
{{-- Results --}}
@if($done)
<div class="bg-green-50 border border-green-200 rounded-xl p-6">
<h3 class="text-lg font-semibold text-green-800 mb-3">{{ __('اكتمل الاستيراد') }}</h3>
<div class="flex items-center gap-6 text-sm">
<span class="text-green-700">{{ __('تم استيراد:') }} <span class="font-bold">{{ $imported }}</span></span>
<span class="text-amber-700">{{ __('تم تخطي:') }} <span class="font-bold">{{ $skipped }}</span></span>
</div>
</div>
@endif
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800 mb-6">{{ __('الملف الشخصي') }}</h1>
{{-- Profile Info --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('المعلومات الشخصية') }}</h2>
@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
<form wire:submit="updateProfile" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }}</label>
<input type="text" wire:model="name_ar" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('name_ar') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالإنجليزية') }}</label>
<input type="text" wire:model="name" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('البريد الإلكتروني') }}</label>
<input type="email" wire:model="email" dir="ltr" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="updateProfile"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
<span wire:loading.remove wire:target="updateProfile">{{ __('حفظ التغييرات') }}</span>
<span wire:loading wire:target="updateProfile">{{ __('جارٍ الحفظ...') }}</span>
</button>
</form>
</div>
{{-- Change Password --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('تغيير كلمة المرور') }}</h2>
@if(session('password_success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">{{ session('password_success') }}</div>
@endif
<form wire:submit="changePassword" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('كلمة المرور الحالية') }}</label>
<input type="password" wire:model="current_password" dir="ltr" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('current_password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('كلمة المرور الجديدة') }}</label>
<input type="password" wire:model="new_password" dir="ltr" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('new_password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تأكيد كلمة المرور') }}</label>
<input type="password" wire:model="new_password_confirmation" dir="ltr" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="changePassword"
class="px-6 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 text-sm font-medium">
<span wire:loading.remove wire:target="changePassword">{{ __('تغيير كلمة المرور') }}</span>
<span wire:loading wire:target="changePassword">{{ __('جارٍ التغيير...') }}</span>
</button>
</form>
</div>
</div>
<!DOCTYPE html>
<html dir="rtl" lang="ar">
<head>
<meta charset="UTF-8">
<title>{{ __('التقرير المالي اليومي') }} - {{ $date }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Cairo', 'Noto Sans Arabic', sans-serif; font-size: 12px; direction: rtl; padding: 20px; }
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #333; padding-bottom: 10px; }
.header h1 { font-size: 18px; margin-bottom: 5px; }
.header p { color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: start; font-size: 11px; }
th { background: #f5f5f5; font-weight: bold; }
.total-row { font-weight: bold; background: #f0f9ff; }
.section-title { font-size: 14px; font-weight: bold; margin: 15px 0 8px; padding: 4px 8px; background: #e5e7eb; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 15px; }
.summary-box { border: 1px solid #ddd; padding: 10px; text-align: center; border-radius: 4px; }
.summary-box .label { font-size: 10px; color: #666; }
.summary-box .value { font-size: 16px; font-weight: bold; margin-top: 4px; }
.ltr { direction: ltr; text-align: left; }
@media print { body { padding: 0; } }
</style>
</head>
<body onload="window.print()">
<div class="header">
<h1>{{ __('التقرير المالي اليومي') }}</h1>
<p>{{ $date }}</p>
</div>
{{-- Summary --}}
<div class="summary-grid">
<div class="summary-box">
<div class="label">{{ __('إجمالي المدفوعات') }}</div>
<div class="value ltr">{{ number_format($totalPayments / 100, 2) }} ج.م</div>
</div>
<div class="summary-box">
<div class="label">{{ __('مبيعات نقطة البيع') }}</div>
<div class="value ltr">{{ number_format($totalPOS / 100, 2) }} ج.م</div>
</div>
<div class="summary-box">
<div class="label">{{ __('الإجمالي الكلي') }}</div>
<div class="value ltr">{{ number_format($grandTotal / 100, 2) }} ج.م</div>
</div>
</div>
{{-- Method Breakdown --}}
@if($methodBreakdown->isNotEmpty())
<div class="section-title">{{ __('تقسيم حسب طريقة الدفع') }}</div>
<table>
<thead><tr><th>{{ __('الطريقة') }}</th><th>{{ __('العدد') }}</th><th>{{ __('المبلغ') }}</th></tr></thead>
<tbody>
@foreach($methodBreakdown as $method => $data)
<tr>
<td>{{ $method }}</td>
<td class="ltr">{{ $data['count'] }}</td>
<td class="ltr">{{ number_format($data['total'] / 100, 2) }} ج.م</td>
</tr>
@endforeach
</tbody>
</table>
@endif
{{-- Payments --}}
@if($payments->isNotEmpty())
<div class="section-title">{{ __('المدفوعات') }} ({{ $payments->count() }})</div>
<table>
<thead><tr><th>#</th><th>{{ __('المرجع') }}</th><th>{{ __('المبلغ') }}</th><th>{{ __('الطريقة') }}</th><th>{{ __('الفاتورة') }}</th><th>{{ __('بواسطة') }}</th></tr></thead>
<tbody>
@foreach($payments as $i => $payment)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $payment->reference ?? '-' }}</td>
<td class="ltr">{{ number_format($payment->amount / 100, 2) }}</td>
<td>{{ $payment->method?->value ?? '' }}</td>
<td>{{ $payment->invoice?->invoice_number ?? '-' }}</td>
<td>{{ $payment->createdBy?->name ?? '' }}</td>
</tr>
@endforeach
<tr class="total-row">
<td colspan="2">{{ __('المجموع') }}</td>
<td class="ltr">{{ number_format($totalPayments / 100, 2) }} ج.م</td>
<td colspan="3"></td>
</tr>
</tbody>
</table>
@endif
{{-- POS --}}
@if($posTransactions->isNotEmpty())
<div class="section-title">{{ __('مبيعات نقطة البيع') }} ({{ $posTransactions->count() }})</div>
<table>
<thead><tr><th>#</th><th>{{ __('رقم العملية') }}</th><th>{{ __('المبلغ') }}</th><th>{{ __('الوقت') }}</th><th>{{ __('الكاشير') }}</th></tr></thead>
<tbody>
@foreach($posTransactions as $i => $txn)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $txn->transaction_number ?? '' }}</td>
<td class="ltr">{{ number_format($txn->total_amount / 100, 2) }}</td>
<td class="ltr">{{ $txn->processed_at?->format('H:i') }}</td>
<td>{{ $txn->user?->name ?? '' }}</td>
</tr>
@endforeach
<tr class="total-row">
<td colspan="2">{{ __('المجموع') }}</td>
<td class="ltr">{{ number_format($totalPOS / 100, 2) }} ج.م</td>
<td colspan="2"></td>
</tr>
</tbody>
</table>
@endif
</body>
</html>
......@@ -16,3 +16,5 @@
Schedule::command('reminders:installments --days=1')->dailyAt('11:30');
Schedule::command('reminders:overdue-invoices')->weeklyOn(1, '09:00');
Schedule::command('reminders:overdue-invoices')->weeklyOn(4, '09:00');
Schedule::command('groups:reconcile-counts')->dailyAt('03:00');
Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30');
......@@ -28,6 +28,8 @@
use App\Livewire\Dashboard;
use App\Livewire\Enrollments\EnrollmentForm;
use App\Livewire\Enrollments\EnrollmentList;
use App\Livewire\Enrollments\TransferGroup;
use App\Livewire\Enrollments\WaitlistManager;
use App\Livewire\Groups\GroupForm;
use App\Livewire\Groups\GroupList;
use App\Livewire\Invoices\InvoiceCreate;
......@@ -95,6 +97,9 @@
// Dashboard
Route::get('/', Dashboard::class)->name('dashboard');
// Profile
Route::get('/profile', \App\Livewire\Profile\UserProfile::class)->name('profile');
// Logout
Route::post('/logout', function () {
Auth::logout();
......@@ -140,6 +145,8 @@
// Participants
Route::get('/participants', ParticipantList::class)->name('participants.list')
->middleware('permission:participants.list');
Route::get('/participants/import', \App\Livewire\Participants\ParticipantImport::class)->name('participants.import')
->middleware('permission:participants.create');
Route::get('/participants/create', ParticipantForm::class)->name('participants.create')
->middleware('permission:participants.create');
Route::get('/participants/{participant}', ParticipantShow::class)->name('participants.show')
......@@ -174,8 +181,12 @@
// Enrollments
Route::get('/enrollments', EnrollmentList::class)->name('enrollments.list')
->middleware('permission:enrollments.list');
Route::get('/enrollments/waitlist', WaitlistManager::class)->name('enrollments.waitlist')
->middleware('permission:enrollments.list');
Route::get('/enrollments/create', EnrollmentForm::class)->name('enrollments.create')
->middleware('permission:enrollments.create');
Route::get('/enrollments/transfer', TransferGroup::class)->name('enrollments.transfer')
->middleware('permission:enrollments.create');
// Wallets
Route::get('/wallets', WalletList::class)->name('wallets.list')
......@@ -222,6 +233,8 @@
// Attendance
Route::get('/attendance', AttendanceList::class)->name('attendance.list')
->middleware('permission:attendance.list');
Route::get('/attendance/quick', \App\Livewire\Attendance\QuickAttendance::class)->name('attendance.quick')
->middleware('permission:attendance.mark');
Route::get('/attendance/{session}', TakeAttendance::class)->name('attendance.take')
->middleware('permission:attendance.mark');
......@@ -301,6 +314,8 @@
// Reports
Route::get('/reports', \App\Livewire\Reports\ReportsPage::class)->name('reports.view')
->middleware('permission:reports.view');
Route::get('/reports/daily-print', [\App\Http\Controllers\ReportPrintController::class, 'dailyFinancial'])
->name('reports.daily-print')->middleware('permission:reports.view');
// Evaluations
Route::get('/evaluations', EvaluationList::class)->name('evaluations.list')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment