Commit 54175068 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add 25+ features: branch scoping, notifications, exports, scheduling, and more

Major additions:
- Branch scoping for all 15 list components via UsesBranchScope trait
- Payment notification listener (email/SMS on every payment)
- Print receipt button on POS, payment wizard, and invoice show
- CSV export for participants, payments, invoices, enrollments
- Global search in topbar (participants, invoices, groups)
- Bulk messaging component (SMS/email by group, status, membership)
- Weekly schedule visual timetable with program/trainer filters
- Trainer dashboard (today's sessions, assigned groups, attendance links)
- Duplicate participant detection in registration wizard
- Enhanced dashboard: today's schedule, overdue invoices, birthdays, recent payments
- 7 scheduled commands: daily summary, birthdays, low stock, expiring enrollments,
  installment reminders, overdue reminders
- Health check endpoint (/health)
- Participant transfer service and group capacity service
- WhatsApp link helper for quick communication
- User activity tracking middleware
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 22d54f2b
<?php
namespace App\Console\Commands;
use App\Domain\Inventory\Models\Product;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NotifyLowStock extends Command
{
protected $signature = 'inventory:notify-low-stock';
protected $description = 'Notify admins about products below minimum stock level';
public function handle(NotificationService $notificationService): int
{
$lowStockProducts = Product::where('track_inventory', true)
->where('is_active', true)
->whereNotNull('min_stock_level')
->whereHas('inventoryLevels', function ($q) {
$q->whereRaw('quantity_on_hand < (SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id)');
})
->with(['inventoryLevels', 'branch'])
->get();
if ($lowStockProducts->isEmpty()) {
$this->info('No low stock products found.');
return self::SUCCESS;
}
$grouped = $lowStockProducts->groupBy('academy_id');
foreach ($grouped as $academyId => $products) {
$productList = $products->map(fn ($p) => "{$p->name_ar} ({$p->inventoryLevels->first()?->quantity_on_hand}/{$p->min_stock_level})")
->implode(', ');
$admins = DB::table('users')
->where('academy_id', $academyId)
->where('status', 'active')
->whereIn('id', function ($q) {
$q->select('user_id')->from('role_user')
->whereIn('role_id', function ($rq) {
$rq->select('id')->from('roles')->where('level', '>=', 70);
});
})
->get();
foreach ($admins as $admin) {
try {
$notificationService->send(
eventType: 'low_stock_alert',
recipientType: 'user',
recipientId: $admin->id,
academyId: $academyId,
variables: [
'product_count' => (string) $products->count(),
'product_list' => $productList,
],
recipientEmail: $admin->email,
);
} catch (\Throwable $e) {
Log::warning("Low stock notification failed: " . $e->getMessage());
}
}
$this->info("Notified admins for academy {$academyId}: {$products->count()} low-stock products.");
}
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Identity\Models\Person;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendBirthdayGreetings extends Command
{
protected $signature = 'notifications:birthdays';
protected $description = 'Send birthday greetings to participants with birthdays today';
public function handle(NotificationService $notificationService): int
{
$today = now();
$people = Person::whereRaw("EXTRACT(MONTH FROM date_of_birth) = ? AND EXTRACT(DAY FROM date_of_birth) = ?", [
$today->month, $today->day,
])->whereHas('participants', fn ($q) => $q->where('status', 'active'))
->with('participants')
->get();
$sent = 0;
foreach ($people as $person) {
$participant = $person->participants->first();
if (!$participant) {
continue;
}
$age = $person->date_of_birth?->diffInYears($today);
try {
$notificationService->send(
eventType: 'birthday_greeting',
recipientType: 'participant',
recipientId: $participant->id,
academyId: $participant->academy_id,
variables: [
'participant_name' => $person->name_ar ?? $person->name ?? '',
'age' => (string) $age,
],
recipientEmail: $person->email,
recipientPhone: $person->phone,
);
$sent++;
} catch (\Throwable $e) {
Log::warning("Birthday greeting failed for person {$person->id}: " . $e->getMessage());
}
}
$this->info("Sent {$sent} birthday greetings.");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Models\Payment;
use App\Domain\Identity\Models\Organization;
use App\Domain\Notification\Services\NotificationService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SendDailySummary extends Command
{
protected $signature = 'summary:daily';
protected $description = 'Send daily summary to academy admins';
public function handle(NotificationService $notificationService): int
{
$yesterday = now()->subDay()->toDateString();
$academies = Organization::where('is_active', true)->get();
foreach ($academies as $academy) {
try {
$payments = Payment::where('academy_id', $academy->id)
->where('status', 'confirmed')
->whereDate('payment_date', $yesterday)
->get();
$totalCollected = $payments->sum('amount');
$paymentCount = $payments->count();
$newEnrollments = Enrollment::where('academy_id', $academy->id)
->whereDate('enrollment_date', $yesterday)
->count();
$newParticipants = Participant::where('academy_id', $academy->id)
->whereDate('created_at', $yesterday)
->count();
$methodBreakdown = $payments->groupBy(fn ($p) => $p->method?->value ?? 'other')
->map(fn ($group) => number_format($group->sum('amount') / 100, 2))
->toArray();
$variables = [
'date' => $yesterday,
'total_collected' => number_format($totalCollected / 100, 2),
'payment_count' => (string) $paymentCount,
'new_enrollments' => (string) $newEnrollments,
'new_participants' => (string) $newParticipants,
'cash_total' => $methodBreakdown['cash'] ?? '0.00',
'card_total' => $methodBreakdown['card'] ?? '0.00',
];
$admins = DB::table('users')
->where('academy_id', $academy->id)
->where('status', 'active')
->whereIn('id', function ($q) {
$q->select('user_id')->from('role_user')
->whereIn('role_id', function ($rq) {
$rq->select('id')->from('roles')
->where('level', '>=', 80);
});
})
->get();
foreach ($admins as $admin) {
$notificationService->send(
eventType: 'daily_summary',
recipientType: 'user',
recipientId: $admin->id,
academyId: $academy->id,
variables: $variables,
recipientEmail: $admin->email,
);
}
$this->info("Sent daily summary for {$academy->name} ({$paymentCount} payments, " . number_format($totalCollected / 100, 2) . " EGP)");
} catch (\Throwable $e) {
Log::error("Daily summary failed for academy {$academy->id}: " . $e->getMessage());
$this->error("Failed for {$academy->name}: " . $e->getMessage());
}
}
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Notification\Services\NotificationService;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendExpiringSoonReminders extends Command
{
protected $signature = 'reminders:expiring-enrollments {--days=7 : Days before expiry to send reminder}';
protected $description = 'Send reminders for enrollments expiring soon';
public function handle(NotificationService $notificationService): int
{
$days = (int) $this->option('days');
$targetDate = now()->addDays($days)->toDateString();
$enrollments = Enrollment::with(['participant.person', 'group.program'])
->where('status', 'active')
->whereDate('expiry_date', $targetDate)
->get();
$sent = 0;
foreach ($enrollments as $enrollment) {
$participant = $enrollment->participant;
$person = $participant?->person;
if (!$person) {
continue;
}
try {
$notificationService->send(
eventType: 'enrollment_expiring',
recipientType: 'participant',
recipientId: $participant->id,
academyId: $enrollment->academy_id,
variables: [
'participant_name' => $person->name_ar ?? $person->name ?? '',
'program_name' => $enrollment->group?->program?->name_ar ?? '',
'group_name' => $enrollment->group?->name_ar ?? '',
'expiry_date' => $enrollment->expiry_date?->format('Y-m-d') ?? '',
'days_remaining' => (string) $days,
],
recipientEmail: $person->email,
recipientPhone: $person->phone,
);
$sent++;
} catch (\Throwable $e) {
Log::warning("Failed to send expiry reminder for enrollment {$enrollment->id}: " . $e->getMessage());
}
}
$this->info("Sent {$sent} expiring enrollment reminders ({$days} days before expiry).");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Models\PaymentPlanInstallment;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendInstallmentReminders extends Command
{
protected $signature = 'reminders:installments {--days=3 : Days before due date to remind}';
protected $description = 'Send reminders for upcoming installment payments';
public function handle(NotificationService $notificationService): int
{
$days = (int) $this->option('days');
$targetDate = now()->addDays($days)->toDateString();
$installments = PaymentPlanInstallment::where('status', 'pending')
->whereDate('due_date', $targetDate)
->with(['paymentPlan.invoice.participant.person'])
->get();
$sent = 0;
foreach ($installments as $installment) {
$participant = $installment->paymentPlan?->invoice?->participant;
$person = $participant?->person;
if (!$person) {
continue;
}
try {
$notificationService->send(
eventType: 'installment_due',
recipientType: 'participant',
recipientId: $participant->id,
academyId: $installment->paymentPlan->academy_id ?? $participant->academy_id,
variables: [
'participant_name' => $person->name_ar ?? $person->name ?? '',
'amount' => number_format($installment->amount / 100, 2),
'due_date' => $installment->due_date?->format('Y-m-d') ?? '',
'installment_number' => (string) $installment->installment_number,
'days_remaining' => (string) $days,
],
recipientEmail: $person->email,
recipientPhone: $person->phone,
);
$sent++;
} catch (\Throwable $e) {
Log::warning("Installment reminder failed for installment {$installment->id}: " . $e->getMessage());
}
}
$this->info("Sent {$sent} installment reminders ({$days} days before due).");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendOverdueReminders extends Command
{
protected $signature = 'reminders:overdue-invoices';
protected $description = 'Send reminders for overdue invoices';
public function handle(NotificationService $notificationService): int
{
$invoices = Invoice::whereIn('status', ['overdue', 'partially_paid'])
->where('due_date', '<', now()->toDateString())
->with(['participant.person'])
->get();
$sent = 0;
foreach ($invoices as $invoice) {
$participant = $invoice->participant;
$person = $participant?->person;
if (!$person) {
continue;
}
$daysOverdue = now()->diffInDays($invoice->due_date);
$balanceDue = $invoice->total_amount - $invoice->paid_amount;
try {
$notificationService->send(
eventType: 'invoice_overdue_reminder',
recipientType: 'participant',
recipientId: $participant->id,
academyId: $invoice->academy_id,
variables: [
'participant_name' => $person->name_ar ?? $person->name ?? '',
'invoice_number' => $invoice->invoice_number ?? '',
'balance_due' => number_format($balanceDue / 100, 2),
'due_date' => $invoice->due_date?->format('Y-m-d') ?? '',
'days_overdue' => (string) $daysOverdue,
],
recipientEmail: $person->email,
recipientPhone: $person->phone,
);
$sent++;
} catch (\Throwable $e) {
Log::warning("Overdue reminder failed for invoice {$invoice->id}: " . $e->getMessage());
}
}
$this->info("Sent {$sent} overdue invoice reminders.");
return self::SUCCESS;
}
}
<?php
namespace App\Domain\Financial\Listeners;
use App\Domain\Financial\Events\PaymentReceived;
use App\Domain\Notification\Services\NotificationService;
use App\Domain\Participant\Models\Participant;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SendPaymentNotification implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(PaymentReceived $event): void
{
$payment = $event->payment;
// Only send notification if payer is a Participant
if ($payment->payer_type !== Participant::class) {
return;
}
try {
$participant = Participant::with('person')->find($payment->payer_id);
if (!$participant || !$participant->person) {
Log::warning('SendPaymentNotification: participant or person not found', [
'payment_id' => $payment->id,
'payer_id' => $payment->payer_id,
]);
return;
}
$person = $participant->person;
// Format amount from piasters to pounds
$formattedAmount = number_format($payment->amount / 100, 2);
// Get Arabic method label
$methodLabel = $payment->method?->label() ?? 'غير محدد';
// Format payment date
$paymentDate = $payment->payment_date?->format('Y-m-d') ?? '';
$variables = [
'participant_name' => $person->name_ar ?? $person->name ?? '',
'amount' => $formattedAmount,
'method' => $methodLabel,
'date' => $paymentDate,
'receipt_number' => $payment->reference ?? '',
'invoice_number' => $payment->invoice?->invoice_number ?? '',
];
$this->notificationService->send(
eventType: 'payment.received',
recipientType: 'participant',
recipientId: $participant->id,
academyId: $payment->academy_id,
variables: $variables,
recipientEmail: $person->email,
recipientPhone: $person->phone,
);
} catch (\Throwable $e) {
Log::error('SendPaymentNotification failed: ' . $e->getMessage(), [
'payment_id' => $payment->id,
]);
}
}
public function failed(PaymentReceived $event, \Throwable $exception): void
{
Log::critical('SendPaymentNotification PERMANENTLY FAILED', [
'payment_id' => $event->payment->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Participant\Services;
use App\Domain\Identity\Models\Person;
use Illuminate\Support\Collection;
class DuplicateDetectionService
{
/**
* Find potential duplicates for a new registration.
* Returns a collection of possible matches with confidence scores.
*/
public function findPotentialDuplicates(string $name, ?string $phone = null, ?string $email = null, ?string $dateOfBirth = null): Collection
{
$matches = collect();
// Exact phone match (highest confidence)
if ($phone) {
$normalized = $this->normalizePhone($phone);
$phoneMatches = Person::where('phone', $normalized)
->orWhere('phone', $phone)
->with('participant')
->get();
foreach ($phoneMatches as $person) {
$matches->push([
'person' => $person,
'confidence' => 95,
'match_reason' => 'phone_exact',
]);
}
}
// Exact email match
if ($email) {
$emailMatches = Person::where('email', strtolower($email))
->with('participant')
->get()
->filter(fn ($p) => !$matches->contains(fn ($m) => $m['person']->id === $p->id));
foreach ($emailMatches as $person) {
$matches->push([
'person' => $person,
'confidence' => 90,
'match_reason' => 'email_exact',
]);
}
}
// Name similarity match
if ($name) {
$nameMatches = Person::where(function ($q) use ($name) {
$q->where('name_ar', 'ilike', "%{$name}%")
->orWhere('name', 'ilike', "%{$name}%");
})->with('participant')
->limit(10)
->get()
->filter(fn ($p) => !$matches->contains(fn ($m) => $m['person']->id === $p->id));
foreach ($nameMatches as $person) {
$confidence = 60;
// Boost confidence if DOB also matches
if ($dateOfBirth && $person->date_of_birth?->format('Y-m-d') === $dateOfBirth) {
$confidence = 85;
}
$matches->push([
'person' => $person,
'confidence' => $confidence,
'match_reason' => $confidence >= 85 ? 'name_and_dob' : 'name_similar',
]);
}
}
return $matches->sortByDesc('confidence')->values();
}
private function normalizePhone(string $phone): string
{
$phone = preg_replace('/[\s\-\(\)]/', '', $phone);
if (str_starts_with($phone, '0')) {
$phone = '+20' . substr($phone, 1);
}
if (!str_starts_with($phone, '+')) {
$phone = '+20' . $phone;
}
return $phone;
}
}
<?php
namespace App\Domain\Participant\Services;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB;
class TransferService
{
public function transferToBranch(Participant $participant, int $newBranchId, string $reason, $actor): Participant
{
if ($participant->branch_id === $newBranchId) {
throw new DomainException('المشترك مسجل بالفعل في هذا الفرع');
}
if ($participant->status === 'blacklisted') {
throw new DomainException('لا يمكن نقل مشترك محظور');
}
return DB::transaction(function () use ($participant, $newBranchId, $reason, $actor) {
$oldBranchId = $participant->branch_id;
Enrollment::where('participant_id', $participant->id)
->where('status', 'active')
->update(['status' => 'cancelled']);
$participant->update([
'branch_id' => $newBranchId,
'status' => 'active',
'notes' => trim(($participant->notes ?? '') . "\nنقل من فرع {$oldBranchId} - السبب: {$reason} - بتاريخ " . now()->format('Y-m-d')),
]);
return $participant->fresh();
});
}
}
......@@ -2,6 +2,7 @@
namespace App\Domain\Training\Models;
use App\Domain\Facility\Models\Facility;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
......@@ -66,6 +67,11 @@ public function rescheduledTo(): BelongsTo
return $this->belongsTo(self::class, 'rescheduled_to_id');
}
public function facility(): BelongsTo
{
return $this->belongsTo(Facility::class);
}
public function attendanceRecords(): HasMany
{
return $this->hasMany(\App\Domain\Attendance\Models\AttendanceRecord::class, 'training_session_id');
......
<?php
namespace App\Domain\Training\Services;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use Illuminate\Support\Facades\DB;
class GroupCapacityService
{
public function refreshCount(TrainingGroup $group): void
{
$activeCount = Enrollment::where('training_group_id', $group->id)
->where('status', 'active')
->count();
$group->update(['current_count' => $activeCount]);
if ($activeCount >= $group->max_capacity && $group->status === 'active') {
$group->update(['status' => 'full']);
} elseif ($activeCount < $group->max_capacity && $group->status === 'full') {
$group->update(['status' => 'active']);
}
}
public function getAvailableSpots(TrainingGroup $group): int
{
return max(0, $group->max_capacity - $group->current_count);
}
public function hasAvailability(TrainingGroup $group): bool
{
return $this->getAvailableSpots($group) > 0 || $group->allow_waitlist;
}
public function getWaitlistCount(TrainingGroup $group): int
{
return Enrollment::where('training_group_id', $group->id)
->where('status', 'waitlisted')
->count();
}
public function promoteFromWaitlist(TrainingGroup $group): ?Enrollment
{
if ($this->getAvailableSpots($group) <= 0) {
return null;
}
$next = Enrollment::where('training_group_id', $group->id)
->where('status', 'waitlisted')
->orderBy('created_at')
->first();
if ($next) {
DB::transaction(function () use ($next, $group) {
$next->update(['status' => 'pending']);
$this->refreshCount($group);
});
}
return $next;
}
}
<?php
if (!function_exists('whatsapp_link')) {
function whatsapp_link(string $phone, string $message = ''): string
{
$phone = preg_replace('/[\s\-\(\)]/', '', $phone);
if (str_starts_with($phone, '0')) {
$phone = '20' . substr($phone, 1);
} elseif (str_starts_with($phone, '+')) {
$phone = substr($phone, 1);
}
$url = "https://wa.me/{$phone}";
if ($message) {
$url .= '?text=' . urlencode($message);
}
return $url;
}
}
<?php
namespace App\Http\Controllers;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
public function participants(Request $request): StreamedResponse
{
Gate::authorize('participants.list');
$branchId = session('active_branch_id');
$query = Participant::with(['person', 'branch', 'primaryActivity'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->orderByDesc('created_at');
return $this->streamCsv('participants', [
'الاسم', 'الهاتف', 'البريد', 'الحالة', 'نوع العضوية', 'رقم العضوية', 'الفرع', 'النشاط', 'تاريخ التسجيل',
], $query, function ($p) {
return [
$p->person?->name_ar ?? $p->person?->name ?? '',
$p->person?->phone ?? '',
$p->person?->email ?? '',
$p->status ?? '',
$p->membership_type ?? '',
$p->membership_id ?? '',
$p->branch?->name_ar ?? '',
$p->primaryActivity?->name_ar ?? '',
$p->created_at?->format('Y-m-d'),
];
});
}
public function payments(Request $request): StreamedResponse
{
Gate::authorize('invoices.list');
$branchId = session('active_branch_id');
$from = $request->get('from', now()->startOfMonth()->toDateString());
$to = $request->get('to', now()->toDateString());
$query = Payment::with(['invoice', 'createdBy'])
->where('status', 'confirmed')
->whereBetween('payment_date', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderByDesc('payment_date');
return $this->streamCsv('payments', [
'التاريخ', 'المرجع', 'المبلغ', 'الطريقة', 'رقم الفاتورة', 'بواسطة',
], $query, function ($p) {
return [
$p->payment_date?->format('Y-m-d'),
$p->reference ?? '',
number_format($p->amount / 100, 2),
$p->method?->value ?? '',
$p->invoice?->invoice_number ?? '',
$p->createdBy?->name ?? '',
];
});
}
public function invoices(Request $request): StreamedResponse
{
Gate::authorize('invoices.list');
$branchId = session('active_branch_id');
$from = $request->get('from', now()->startOfMonth()->toDateString());
$to = $request->get('to', now()->toDateString());
$query = Invoice::with(['participant.person'])
->whereBetween('created_at', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->orderByDesc('created_at');
return $this->streamCsv('invoices', [
'رقم الفاتورة', 'المشترك', 'الإجمالي', 'المدفوع', 'المستحق', 'الحالة', 'التاريخ', 'تاريخ الاستحقاق',
], $query, function ($inv) {
return [
$inv->invoice_number ?? '',
$inv->participant?->person?->name_ar ?? '',
number_format($inv->total_amount / 100, 2),
number_format($inv->paid_amount / 100, 2),
number_format(($inv->total_amount - $inv->paid_amount) / 100, 2),
$inv->status ?? '',
$inv->created_at?->format('Y-m-d'),
$inv->due_date?->format('Y-m-d') ?? '',
];
});
}
public function enrollments(Request $request): StreamedResponse
{
Gate::authorize('enrollments.list');
$branchId = session('active_branch_id');
$query = Enrollment::with(['participant.person', 'group.program', 'group.branch'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->orderByDesc('enrollment_date');
return $this->streamCsv('enrollments', [
'المشترك', 'البرنامج', 'المجموعة', 'الحالة', 'تاريخ التسجيل', 'الفرع',
], $query, function ($e) {
return [
$e->participant?->person?->name_ar ?? '',
$e->group?->program?->name_ar ?? '',
$e->group?->name_ar ?? '',
$e->status ?? '',
$e->enrollment_date?->format('Y-m-d') ?? '',
$e->group?->branch?->name_ar ?? '',
];
});
}
private function streamCsv(string $filename, array $headers, $query, callable $rowMapper): StreamedResponse
{
$date = now()->format('Y-m-d');
return response()->streamDownload(function () use ($headers, $query, $rowMapper) {
$handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($handle, $headers);
$query->chunk(500, function ($records) use ($handle, $rowMapper) {
foreach ($records as $record) {
fputcsv($handle, $rowMapper($record));
}
});
fclose($handle);
}, "{$filename}-{$date}.csv", [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
}
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class HealthController extends Controller
{
public function __invoke(): JsonResponse
{
$checks = [];
try {
DB::connection()->getPdo();
$checks['database'] = 'ok';
} catch (\Throwable) {
$checks['database'] = 'fail';
}
try {
Cache::put('health_check', true, 5);
$checks['cache'] = Cache::get('health_check') ? 'ok' : 'fail';
} catch (\Throwable) {
$checks['cache'] = 'fail';
}
$checks['php_version'] = PHP_VERSION;
$checks['laravel_version'] = app()->version();
$checks['timestamp'] = now()->toIso8601String();
$allOk = !in_array('fail', $checks);
return response()->json([
'status' => $allOk ? 'healthy' : 'degraded',
'checks' => $checks,
], $allOk ? 200 : 503);
}
}
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class LogUserActivity
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->user() && !$request->ajax() && $request->method() === 'GET') {
DB::table('users')
->where('id', $request->user()->id)
->update(['last_active_at' => now()]);
}
return $response;
}
}
......@@ -7,6 +7,7 @@
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Scheduling\Services\AssignmentService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
......@@ -20,7 +21,7 @@
#[Title('التكليفات')]
class AssignmentList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -102,8 +103,14 @@ public function cancel(string $uuid, AssignmentService $service): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Assignment::query()
->with(['user', 'assignable'])
->when($branchId, fn ($q) => $q->where(function ($sub) use ($branchId) {
$sub->whereHasMorph('assignable', [TrainingGroup::class], fn ($gq) => $gq->where('branch_id', $branchId))
->orWhereHasMorph('assignable', [TrainingSession::class], fn ($sq) => $sq->whereHas('group', fn ($gq) => $gq->where('branch_id', $branchId)));
}))
->when($this->search, function ($q) {
$search = $this->search;
$q->whereHas('user', function ($q2) use ($search) {
......
......@@ -2,6 +2,7 @@
namespace App\Livewire\Attendance;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -13,7 +14,7 @@
#[Title('سجل الحضور')]
class AttendanceList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -48,8 +49,11 @@ public function updatedDateTo(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = TrainingSession::query()
->with(['group'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($gq) => $gq->where('branch_id', $branchId)))
->withCount([
'attendanceRecords',
'attendanceRecords as present_count' => fn ($q) => $q->whereIn('status', ['present', 'late', 'partial']),
......
......@@ -3,6 +3,7 @@
namespace App\Livewire\CashSessions;
use App\Domain\Financial\Models\CashSession;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -13,7 +14,7 @@
#[Title('الورديات النقدية')]
class CashSessionList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $status = '';
......@@ -25,8 +26,11 @@ public function updatedStatus(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = CashSession::query()
->with(['user', 'branch'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('opened_at');
......
......@@ -7,6 +7,7 @@
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Identity\Models\Person;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Models\POSTransaction;
......@@ -88,6 +89,40 @@ public function render()
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
$overdueInvoices = Invoice::where('status', InvoiceStatus::Overdue)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('participant.person')
->orderBy('due_date')
->limit(5)
->get();
$todaySchedule = TrainingSession::where('session_date', $today)
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->with(['group.program', 'group.branch'])
->orderBy('start_time')
->get();
$upcomingBirthdays = Person::whereRaw("EXTRACT(MONTH FROM date_of_birth) = ? AND EXTRACT(DAY FROM date_of_birth) >= ?", [
now()->month, now()->day,
])
->orWhereRaw("EXTRACT(MONTH FROM date_of_birth) = ? AND EXTRACT(DAY FROM date_of_birth) < ?", [
now()->addMonth()->month, now()->day,
])
->whereHas('participants', function ($q) use ($branchId) {
$q->where('status', 'active')
->when($branchId, fn ($pq) => $pq->where('branch_id', $branchId));
})
->orderByRaw("EXTRACT(MONTH FROM date_of_birth), EXTRACT(DAY FROM date_of_birth)")
->limit(5)
->get();
$recentPayments = Payment::where('status', 'confirmed')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('createdBy')
->orderByDesc('created_at')
->limit(5)
->get();
return view('livewire.dashboard', [
'stats' => $stats,
'financial' => $financial,
......@@ -95,6 +130,10 @@ public function render()
'lowStockCount' => $lowStockCount,
'nearFullGroups' => $nearFullGroups,
'recentEnrollments' => $recentEnrollments,
'overdueInvoices' => $overdueInvoices,
'todaySchedule' => $todaySchedule,
'upcomingBirthdays' => $upcomingBirthdays,
'recentPayments' => $recentPayments,
]);
}
}
......@@ -2,6 +2,7 @@
namespace App\Livewire\Enrollments;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\EnrollmentService;
......@@ -16,7 +17,7 @@
#[Title('التسجيلات')]
class EnrollmentList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -63,8 +64,11 @@ public function cancelEnrollment(int $enrollmentId, string $reason): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Enrollment::query()
->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) {
......
......@@ -8,6 +8,7 @@
use App\Domain\Facility\Services\FacilityService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -18,7 +19,7 @@
#[Title('المنشآت')]
class FacilityList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -65,8 +66,11 @@ public function delete(string $uuid, FacilityService $service): void
public function render()
{
$branchId = $this->branch_id ?: $this->getActiveBranchId();
$query = Facility::query()
->with('branch')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
......@@ -77,7 +81,6 @@ public function render()
})
->when($this->status, fn ($q) => $q->where('status', $this->status))
->when($this->type, fn ($q) => $q->where('type', $this->type))
->when($this->branch_id, fn ($q) => $q->where('branch_id', $this->branch_id))
->orderBy('sort_order')
->orderBy('name_ar');
......
......@@ -10,6 +10,7 @@
use App\Domain\Financial\Models\Transaction;
use App\Domain\Identity\Models\Branch;
use App\Domain\Inventory\Models\PurchaseOrder;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -20,6 +21,8 @@
#[Title('النظرة المالية العامة')]
class FinancialOverview extends Component
{
use UsesBranchScope;
#[Url]
public string $period = 'this_month';
......@@ -33,6 +36,13 @@ public function mount()
public function render()
{
if (!$this->branch_id) {
$activeBranch = $this->getActiveBranchId();
if ($activeBranch) {
$this->branch_id = (string) $activeBranch;
}
}
[$from, $to] = $this->resolvePeriod();
$branches = Branch::orderBy('name_ar')->get();
......
<?php
namespace App\Livewire;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingGroup;
use Livewire\Component;
class GlobalSearch extends Component
{
use UsesBranchScope;
public string $query = '';
public bool $showResults = false;
public array $results = [];
public function updatedQuery(): void
{
if (strlen($this->query) < 2) {
$this->results = [];
$this->showResults = false;
return;
}
$this->search();
}
public function search(): void
{
$q = $this->query;
$branchId = $this->getActiveBranchId();
$results = [];
$participants = Participant::with('person')
->when($branchId, fn ($query) => $query->where('branch_id', $branchId))
->where(function ($query) use ($q) {
$query->where('participant_number', 'ilike', "%{$q}%")
->orWhere('membership_id', 'ilike', "%{$q}%")
->orWhereHas('person', function ($pq) use ($q) {
$pq->where('name_ar', 'ilike', "%{$q}%")
->orWhere('name', 'ilike', "%{$q}%")
->orWhere('phone', 'like', "%{$q}%");
});
})
->limit(5)
->get();
foreach ($participants as $p) {
$results[] = [
'type' => 'participant',
'label' => $p->person?->name_ar ?? $p->person?->name ?? $p->participant_number,
'subtitle' => $p->participant_number . ' — ' . ($p->status ?? ''),
'url' => route('participants.show', $p),
];
}
$invoices = Invoice::with('participant.person')
->when($branchId, fn ($query) => $query->where('branch_id', $branchId))
->where(function ($query) use ($q) {
$query->where('invoice_number', 'ilike', "%{$q}%")
->orWhereHas('participant.person', function ($pq) use ($q) {
$pq->where('name_ar', 'ilike', "%{$q}%");
});
})
->limit(3)
->get();
foreach ($invoices as $inv) {
$results[] = [
'type' => 'invoice',
'label' => $inv->invoice_number,
'subtitle' => ($inv->participant?->person?->name_ar ?? '') . ' — ' . number_format($inv->total_amount / 100, 2) . ' ج.م',
'url' => route('invoices.show', $inv),
];
}
$groups = TrainingGroup::with('program')
->when($branchId, fn ($query) => $query->where('branch_id', $branchId))
->where(function ($query) use ($q) {
$query->where('name_ar', 'ilike', "%{$q}%")
->orWhere('name', 'ilike', "%{$q}%");
})
->limit(3)
->get();
foreach ($groups as $g) {
$results[] = [
'type' => 'group',
'label' => $g->name_ar ?? $g->name,
'subtitle' => ($g->program?->name_ar ?? '') . " ({$g->current_count}/{$g->max_capacity})",
'url' => route('groups.edit', $g),
];
}
$this->results = $results;
$this->showResults = !empty($results);
}
public function selectResult(string $url): void
{
$this->redirect($url, navigate: true);
}
public function render()
{
return view('livewire.global-search');
}
}
......@@ -2,6 +2,7 @@
namespace App\Livewire\Groups;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\GroupStatus;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
......@@ -17,7 +18,7 @@
#[Title('المجموعات التدريبية')]
class GroupList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -72,8 +73,11 @@ public function changeStatus(string $uuid, string $newStatus, TrainingGroupServi
public function render()
{
$branchId = $this->getActiveBranchId();
$query = TrainingGroup::query()
->with(['program', 'branch', 'headTrainer'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, fn ($q) => $q->where(function ($sub) {
$sub->where('name_ar', 'ilike', "%{$this->search}%")
->orWhere('name', 'ilike', "%{$this->search}%")
......
......@@ -5,6 +5,7 @@
use App\Domain\Inventory\Models\InventoryLevel;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -16,7 +17,7 @@
#[Title('حركة المخزون')]
class MovementList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -68,11 +69,14 @@ public function updatedWarehouseFilter(): void
public function render()
{
$branchId = $this->getActiveBranchId();
// Query inventory_movements table directly since the model may not exist yet
$query = DB::table('inventory_movements')
->join('products', 'inventory_movements.product_id', '=', 'products.id')
->join('warehouses', 'inventory_movements.warehouse_id', '=', 'warehouses.id')
->leftJoin('users', 'inventory_movements.created_by', '=', 'users.id')
->when($branchId, fn ($q) => $q->where('warehouses.branch_id', $branchId))
->select([
'inventory_movements.*',
'products.name_ar as product_name_ar',
......
......@@ -4,6 +4,7 @@
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductCategory;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -14,7 +15,7 @@
#[Title('المنتجات')]
class ProductList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -48,8 +49,11 @@ public function toggleActive(int $productId): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Product::query()
->with(['category', 'inventoryLevels'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
......
......@@ -3,6 +3,7 @@
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -13,7 +14,7 @@
#[Title('المستودعات')]
class WarehouseList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -25,9 +26,12 @@ public function updatedSearch(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Warehouse::query()
->with(['branch', 'inventoryLevels'])
->withCount('inventoryLevels')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
......
......@@ -4,6 +4,8 @@
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -14,7 +16,7 @@
#[Title('الفواتير')]
class InvoiceList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -34,7 +36,10 @@ public function updatedStatus(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Invoice::query()
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($pq) => $pq->where('branch_id', $branchId)))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
......
<?php
namespace App\Livewire\Messaging;
use App\Domain\Notification\Services\NotificationService;
use App\Domain\Participant\Enums\MembershipType;
use App\Domain\Participant\Enums\ParticipantStatus;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingGroup;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('رسائل جماعية')]
class BulkMessage extends Component
{
use UsesBranchScope;
public string $selectionMode = 'group'; // group, status, membership, custom
public string $selectedGroup = '';
public string $selectedStatus = '';
public string $selectedMembership = '';
public array $selectedParticipants = [];
public string $messageBody = '';
public bool $sendSms = true;
public bool $sendEmail = false;
public bool $showPreview = false;
public bool $isSending = false;
public int $recipientCount = 0;
public int $sentCount = 0;
public int $failedCount = 0;
public bool $sendComplete = false;
public function mount(): void
{
$this->authorize('notifications.manage');
}
public function rules(): array
{
return [
'messageBody' => 'required|string|max:640',
'selectionMode' => 'required|in:group,status,membership,custom',
'selectedGroup' => 'required_if:selectionMode,group',
'selectedStatus' => 'required_if:selectionMode,status',
'selectedMembership' => 'required_if:selectionMode,membership',
'selectedParticipants' => 'required_if:selectionMode,custom|array',
];
}
public function messages(): array
{
return [
'messageBody.required' => __('نص الرسالة مطلوب'),
'messageBody.max' => __('نص الرسالة يجب أن لا يتجاوز 640 حرف'),
'selectedGroup.required_if' => __('يجب اختيار مجموعة'),
'selectedStatus.required_if' => __('يجب اختيار حالة'),
'selectedMembership.required_if' => __('يجب اختيار نوع العضوية'),
'selectedParticipants.required_if' => __('يجب اختيار مشتركين'),
];
}
public function updatedSelectionMode(): void
{
$this->resetPreview();
}
public function updatedSelectedGroup(): void
{
$this->resetPreview();
}
public function updatedSelectedStatus(): void
{
$this->resetPreview();
}
public function updatedSelectedMembership(): void
{
$this->resetPreview();
}
public function updatedSelectedParticipants(): void
{
$this->resetPreview();
}
public function preview(): void
{
$this->recipientCount = $this->getRecipientsQuery()->count();
$this->showPreview = true;
}
public function send(NotificationService $notificationService): void
{
$this->validate();
if ($this->recipientCount === 0) {
session()->flash('error', __('لا يوجد مستلمين للرسالة'));
return;
}
$this->isSending = true;
$this->sentCount = 0;
$this->failedCount = 0;
$academyId = (int) app('current_academy')?->id;
$participants = $this->getRecipientsQuery()
->with('person')
->get();
foreach ($participants as $participant) {
$person = $participant->person;
if (!$person) {
$this->failedCount++;
continue;
}
$phone = $this->sendSms ? $person->phone : null;
$email = $this->sendEmail ? $person->email : null;
if (!$phone && !$email) {
$this->failedCount++;
continue;
}
try {
$notificationService->send(
eventType: 'bulk_message',
recipientType: 'participant',
recipientId: $participant->id,
academyId: $academyId,
variables: [
'message' => $this->messageBody,
'participant_name' => $person->name_ar ?? $person->name ?? '',
],
recipientEmail: $email,
recipientPhone: $phone,
);
$this->sentCount++;
} catch (\Throwable $e) {
Log::warning("Bulk message failed for participant {$participant->id}: " . $e->getMessage());
$this->failedCount++;
}
}
$this->isSending = false;
$this->sendComplete = true;
if ($this->failedCount === 0) {
session()->flash('success', __('تم إرسال الرسالة بنجاح إلى :count مستلم', ['count' => $this->sentCount]));
} else {
session()->flash('warning', __('تم الإرسال: :sent ناجح، :failed فشل', [
'sent' => $this->sentCount,
'failed' => $this->failedCount,
]));
}
}
public function resetForm(): void
{
$this->reset([
'messageBody', 'selectedGroup', 'selectedStatus',
'selectedMembership', 'selectedParticipants',
'showPreview', 'sendComplete', 'sentCount', 'failedCount', 'recipientCount',
]);
$this->selectionMode = 'group';
$this->sendSms = true;
$this->sendEmail = false;
}
public function render()
{
$branchId = $this->getActiveBranchId();
$groups = TrainingGroup::query()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereIn('status', ['active', 'forming', 'full'])
->orderBy('name_ar')
->get(['id', 'name_ar', 'name', 'current_count']);
$allParticipants = Participant::query()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('person:id,name_ar,name')
->whereIn('status', [
ParticipantStatus::Active->value,
ParticipantStatus::Registered->value,
])
->orderByRaw("(SELECT name_ar FROM people WHERE people.id = participants.person_id) ASC")
->get(['id', 'person_id']);
return view('livewire.messaging.bulk-message', [
'groups' => $groups,
'statuses' => ParticipantStatus::cases(),
'membershipTypes' => MembershipType::cases(),
'allParticipants' => $allParticipants,
]);
}
private function getRecipientsQuery()
{
$branchId = $this->getActiveBranchId();
$query = Participant::query()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
return match ($this->selectionMode) {
'group' => $query->whereHas('enrollments', function ($q) {
$q->where('training_group_id', $this->selectedGroup)
->where('status', 'active');
}),
'status' => $query->where('status', $this->selectedStatus),
'membership' => $query->where('membership_type', $this->selectedMembership),
'custom' => $query->whereIn('id', $this->selectedParticipants),
};
}
private function resetPreview(): void
{
$this->showPreview = false;
$this->recipientCount = 0;
$this->sendComplete = false;
}
}
......@@ -3,6 +3,7 @@
namespace App\Livewire\POS;
use App\Domain\POS\Models\POSTransaction;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -13,7 +14,7 @@
#[Title('سجل المبيعات')]
class POSHistory extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -56,8 +57,11 @@ public function updatedPaymentMethodFilter(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = POSTransaction::query()
->with(['participant.person', 'processedBy'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, fn ($q) => $q->where(function ($sq) {
$sq->where('receipt_number', 'ilike', "%{$this->search}%")
->orWhereHas('participant.person', fn ($pq) => $pq->where('name_ar', 'ilike', "%{$this->search}%"));
......
......@@ -42,6 +42,7 @@ class POSTerminal extends Component
public bool $showReceipt = false;
public ?string $lastReceiptNumber = null;
public ?int $lastTransactionTotal = null;
public ?string $lastTransactionUuid = null;
// Cash session
public bool $hasOpenSession = false;
......@@ -259,6 +260,7 @@ public function checkout(POSService $posService): void
$this->lastReceiptNumber = $transaction->receipt_number;
$this->lastTransactionTotal = $transaction->total_amount;
$this->lastTransactionUuid = $transaction->uuid;
$this->showReceipt = true;
// Reset cart
......@@ -279,6 +281,7 @@ public function closeReceipt(): void
$this->showReceipt = false;
$this->lastReceiptNumber = null;
$this->lastTransactionTotal = null;
$this->lastTransactionUuid = null;
}
public function newTransaction(): void
......@@ -292,6 +295,9 @@ public function newTransaction(): void
$this->splitPayments = [];
$this->showCheckout = false;
$this->showReceipt = false;
$this->lastReceiptNumber = null;
$this->lastTransactionTotal = null;
$this->lastTransactionUuid = null;
}
public function render()
......
......@@ -3,6 +3,7 @@
namespace App\Livewire\Participants;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -14,7 +15,7 @@
#[Title('المشتركين')]
class ParticipantList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -47,8 +48,11 @@ public function updatedSkillLevel(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Participant::query()
->with(['person', 'primaryActivity'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
......
......@@ -2,9 +2,14 @@
namespace App\Livewire\Participants;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Participant\Models\Participant;
use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -88,9 +93,53 @@ public function render()
'blacklisted' => 'محظور',
];
// Attendance summary
$participantClass = \App\Domain\Participant\Models\Participant::class;
$attendanceQuery = AttendanceRecord::where('subject_type', $participantClass)
->where('subject_id', $this->participant->id);
$totalSessions = (clone $attendanceQuery)->count();
$presentCount = (clone $attendanceQuery)->where('status', AttendanceStatus::Present)->count();
$lateCount = (clone $attendanceQuery)->where('status', AttendanceStatus::Late)->count();
$absentCount = (clone $attendanceQuery)->whereIn('status', [AttendanceStatus::Absent, AttendanceStatus::NoShow])->count();
$partialCount = (clone $attendanceQuery)->where('status', AttendanceStatus::Partial)->count();
$cancelledExempt = (clone $attendanceQuery)->whereIn('status', [AttendanceStatus::Cancelled, AttendanceStatus::Exempt])->count();
$attendanceDenominator = $totalSessions - $cancelledExempt;
$attendanceRate = $attendanceDenominator > 0
? round(($presentCount + $lateCount + $partialCount) / $attendanceDenominator * 100, 1)
: 0;
// Payment history (last 10)
$recentPayments = Payment::where('payer_type', $participantClass)
->where('payer_id', $this->participant->id)
->orderByDesc('payment_date')
->limit(10)
->get();
// Active enrollments with group and program details
$activeEnrollments = Enrollment::where('participant_id', $this->participant->id)
->where('status', 'active')
->with(['group.headTrainer', 'group.schedules', 'program'])
->get();
// Wallet balance
$wallet = Wallet::where('owner_type', $participantClass)
->where('owner_id', $this->participant->id)
->first();
return view('livewire.participants.participant-show', [
'validTransitions' => $validTransitions,
'statusLabels' => $statusLabels,
'totalSessions' => $totalSessions,
'presentCount' => $presentCount,
'lateCount' => $lateCount,
'absentCount' => $absentCount,
'partialCount' => $partialCount,
'attendanceRate' => $attendanceRate,
'recentPayments' => $recentPayments,
'activeEnrollments' => $activeEnrollments,
'wallet' => $wallet,
]);
}
}
......@@ -2,6 +2,7 @@
namespace App\Livewire\Programs;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\ProgramStatus;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\TrainingProgram;
......@@ -17,7 +18,7 @@
#[Title('البرامج التدريبية')]
class ProgramList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -72,9 +73,12 @@ public function delete(string $uuid, TrainingProgramService $service): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = TrainingProgram::query()
->with(['activity', 'branch'])
->withCount('groups')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, fn ($q) => $q->where(function ($sub) {
$sub->where('name_ar', 'ilike', "%{$this->search}%")
->orWhere('name', 'ilike', "%{$this->search}%");
......
......@@ -39,6 +39,7 @@ class CollectPaymentWizard extends Component
// Result
public bool $completed = false;
public ?string $receipt_number = null;
public ?string $last_payment_uuid = null;
public int $paid_amount = 0;
public function mount(): void
......@@ -160,6 +161,7 @@ public function confirm(PaymentService $service): void
$this->completed = true;
$this->receipt_number = $payment->receipt_number ?? null;
$this->last_payment_uuid = $payment->uuid;
$this->paid_amount = $amountPiasters;
$this->currentStep = 5;
......
......@@ -2,7 +2,7 @@
namespace App\Livewire\Receptionist;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Participant\Services\DuplicateDetectionService;
use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
......@@ -46,6 +46,11 @@ class NewRegistrationWizard extends Component
public string $payment_method = 'cash';
public int $payment_amount = 0;
// Duplicate detection
public array $potentialDuplicates = [];
public bool $duplicateCheckDone = false;
public ?int $useExistingPersonId = null;
// Result
public bool $completed = false;
public ?string $participant_number = null;
......@@ -107,6 +112,67 @@ public function messages(): array
public function nextStep(): void
{
$this->validate();
// Check for duplicates when moving from step 1 to step 2
if ($this->currentStep === 1 && !$this->duplicateCheckDone) {
$this->checkDuplicates();
if (!empty($this->potentialDuplicates)) {
return; // Stay on step 1, show warning
}
}
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
/**
* Check for potential duplicate registrations based on guardian info.
*/
public function checkDuplicates(): void
{
$service = app(DuplicateDetectionService::class);
$matches = $service->findPotentialDuplicates(
name: $this->guardian_name_ar,
phone: $this->guardian_phone ?: null,
);
// Only show matches with confidence >= 80
$highConfidence = $matches->filter(fn ($m) => $m['confidence'] >= 80);
$this->potentialDuplicates = $highConfidence->map(fn ($m) => [
'person_id' => $m['person']->id,
'name' => $m['person']->name_ar ?: $m['person']->name,
'phone' => $m['person']->phone,
'confidence' => $m['confidence'],
'match_reason' => $m['match_reason'],
'has_participant' => $m['person']->participant !== null,
'participant_number' => $m['person']->participant?->participant_number,
])->toArray();
$this->duplicateCheckDone = true;
// If no high-confidence matches, proceed automatically
if (empty($this->potentialDuplicates)) {
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
}
/**
* User chose to proceed with registration despite duplicates.
*/
public function proceedDespiteDuplicates(): void
{
$this->potentialDuplicates = [];
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
/**
* User chose to use an existing person record.
*/
public function useExistingPerson(int $personId): void
{
$this->useExistingPersonId = $personId;
$this->potentialDuplicates = [];
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
......@@ -122,6 +188,23 @@ public function goToStep(int $step): void
}
}
public function updatedGuardianNameAr(): void
{
$this->resetDuplicateCheck();
}
public function updatedGuardianPhone(): void
{
$this->resetDuplicateCheck();
}
private function resetDuplicateCheck(): void
{
$this->duplicateCheckDone = false;
$this->potentialDuplicates = [];
$this->useExistingPersonId = null;
}
public function updatedSelectedActivityId(): void
{
$this->selected_program_id = null;
......@@ -139,7 +222,7 @@ public function confirm(ParticipantService $service): void
// - InvoiceService::create() for the fee
// - PaymentService::recordPayment() if pay_now is true
$result = $service->register([
$registrationData = [
'person' => [
'name_ar' => $this->participant_name_ar,
'name' => $this->participant_name,
......@@ -159,7 +242,14 @@ public function confirm(ParticipantService $service): void
'program_id' => $this->selected_program_id,
'pay_now' => $this->pay_now,
'payment_method' => $this->pay_now ? $this->payment_method : null,
], auth()->user());
];
// If user chose to use an existing person, pass that info
if ($this->useExistingPersonId) {
$registrationData['existing_guardian_person_id'] = $this->useExistingPersonId;
}
$result = $service->register($registrationData, auth()->user());
$this->completed = true;
$this->participant_number = $result->participant_number ?? null;
......
<?php
namespace App\Livewire\Schedule;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('الجدول الأسبوعي')]
class WeeklySchedule extends Component
{
use UsesBranchScope;
#[Url]
public string $programFilter = '';
#[Url]
public string $trainerFilter = '';
public string $weekStart = '';
public function mount(): void
{
$this->authorize('schedules.view');
// Start week on Saturday (Arabic week starts Saturday)
$today = Carbon::today();
$this->weekStart = $today->startOfWeek(Carbon::SATURDAY)->format('Y-m-d');
}
public function previousWeek(): void
{
$this->weekStart = Carbon::parse($this->weekStart)->subWeek()->format('Y-m-d');
}
public function nextWeek(): void
{
$this->weekStart = Carbon::parse($this->weekStart)->addWeek()->format('Y-m-d');
}
public function goToToday(): void
{
$today = Carbon::today();
$this->weekStart = $today->startOfWeek(Carbon::SATURDAY)->format('Y-m-d');
}
public function getWeekDaysProperty(): array
{
$start = Carbon::parse($this->weekStart);
$days = [];
$arabicDayNames = [
Carbon::SATURDAY => 'السبت',
Carbon::SUNDAY => 'الأحد',
Carbon::MONDAY => 'الاثنين',
Carbon::TUESDAY => 'الثلاثاء',
Carbon::WEDNESDAY => 'الأربعاء',
Carbon::THURSDAY => 'الخميس',
];
for ($i = 0; $i < 6; $i++) {
$date = $start->copy()->addDays($i);
$days[] = [
'date' => $date->format('Y-m-d'),
'name' => $arabicDayNames[$date->dayOfWeek] ?? $date->translatedDayName,
'formatted' => $date->format('m/d'),
'isToday' => $date->isToday(),
];
}
return $days;
}
public function getTimeSlotsProperty(): array
{
$slots = [];
for ($hour = 8; $hour <= 22; $hour++) {
$slots[] = sprintf('%02d:00', $hour);
}
return $slots;
}
public function render()
{
$branchId = $this->getActiveBranchId();
$weekEnd = Carbon::parse($this->weekStart)->addDays(5)->format('Y-m-d');
$sessions = TrainingSession::query()
->with(['group.program', 'facility', 'trainer'])
->where('session_date', '>=', $this->weekStart)
->where('session_date', '<=', $weekEnd)
->whereNotIn('status', ['cancelled', 'rescheduled'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($gq) => $gq->where('branch_id', $branchId)))
->when($this->programFilter, fn ($q) => $q->whereHas('group', fn ($gq) => $gq->where('program_id', $this->programFilter)))
->when($this->trainerFilter, fn ($q) => $q->where('trainer_id', $this->trainerFilter))
->orderBy('start_time')
->get();
// Group sessions by date for the grid
$sessionsByDate = $sessions->groupBy(fn ($s) => $s->session_date->format('Y-m-d'));
// Programs for filter
$programs = TrainingProgram::query()
->select('id', 'name_ar')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('name_ar')
->get();
// Trainers for filter
$trainers = User::query()
->select('id', 'name_ar', 'name')
->whereHas('roles', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer']))
->orderBy('name_ar')
->get();
// Assign colors per program for visual distinction
$programColors = $this->getProgramColors($sessions);
return view('livewire.schedule.weekly-schedule', [
'sessionsByDate' => $sessionsByDate,
'programs' => $programs,
'trainers' => $trainers,
'programColors' => $programColors,
]);
}
private function getProgramColors($sessions): array
{
$colors = [
'bg-blue-100 border-blue-400 text-blue-800',
'bg-emerald-100 border-emerald-400 text-emerald-800',
'bg-amber-100 border-amber-400 text-amber-800',
'bg-purple-100 border-purple-400 text-purple-800',
'bg-rose-100 border-rose-400 text-rose-800',
'bg-cyan-100 border-cyan-400 text-cyan-800',
'bg-orange-100 border-orange-400 text-orange-800',
'bg-indigo-100 border-indigo-400 text-indigo-800',
'bg-teal-100 border-teal-400 text-teal-800',
'bg-pink-100 border-pink-400 text-pink-800',
];
$programIds = $sessions->pluck('group.program_id')->unique()->values();
$map = [];
foreach ($programIds as $index => $programId) {
$map[$programId] = $colors[$index % count($colors)];
}
return $map;
}
}
<?php
namespace App\Livewire\Trainer;
use App\Domain\Scheduling\Enums\AssignmentStatus;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('لوحة المدرب')]
class TrainerDashboard extends Component
{
use UsesBranchScope;
public ?int $branchId = null;
public function mount(): void
{
$this->authorize('attendance.mark');
$this->branchId = $this->getActiveBranchIdOrFail();
}
public function render()
{
$user = auth()->user();
$today = now()->toDateString();
// Get group IDs assigned to this trainer (active assignments)
$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();
// Upcoming sessions (next 7 days, excluding today)
$upcomingSessions = TrainingSession::whereIn('training_group_id', $assignedGroupIds)
->where('session_date', '>', $today)
->where('session_date', '<=', now()->addDays(7)->toDateString())
->where('status', SessionStatus::Scheduled)
->with('group')
->orderBy('session_date')
->orderBy('start_time')
->get();
// Assigned groups with participant count
$assignedGroups = TrainingGroup::whereIn('id', $assignedGroupIds)
->whereIn('status', ['active', 'forming', 'full'])
->orderBy('name_ar')
->get();
// Monthly stats: sessions conducted this month
$monthStart = now()->startOfMonth()->toDateString();
$monthEnd = now()->endOfMonth()->toDateString();
$sessionsThisMonth = TrainingSession::whereIn('training_group_id', $assignedGroupIds)
->whereBetween('session_date', [$monthStart, $monthEnd])
->where('status', SessionStatus::Completed)
->count();
$totalSessionsThisMonth = TrainingSession::whereIn('training_group_id', $assignedGroupIds)
->whereBetween('session_date', [$monthStart, $monthEnd])
->whereIn('status', [SessionStatus::Scheduled, SessionStatus::InProgress, SessionStatus::Completed])
->count();
return view('livewire.trainer.trainer-dashboard', [
'todaySessions' => $todaySessions,
'upcomingSessions' => $upcomingSessions,
'assignedGroups' => $assignedGroups,
'sessionsThisMonth' => $sessionsThisMonth,
'totalSessionsThisMonth' => $totalSessionsThisMonth,
]);
}
}
......@@ -3,6 +3,8 @@
namespace App\Livewire\Wallets;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -13,7 +15,7 @@
#[Title('المحافظ')]
class WalletList extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -33,8 +35,11 @@ public function updatedActiveFilter(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Wallet::query()
->with('owner')
->when($branchId, fn ($q) => $q->whereHasMorph('owner', [Participant::class], fn ($pq) => $pq->where('branch_id', $branchId)))
->when($this->search, function ($q) {
$q->whereHasMorph('owner', [\App\Domain\Participant\Models\Participant::class], function ($pq) {
$pq->whereHas('person', function ($ppq) {
......
......@@ -24,6 +24,7 @@ class EventServiceProvider extends ServiceProvider
],
\App\Domain\Financial\Events\PaymentReceived::class => [
\App\Domain\Financial\Listeners\SendPaymentConfirmation::class,
\App\Domain\Financial\Listeners\SendPaymentNotification::class,
\App\Domain\Financial\Listeners\UpdateCashSessionTotals::class,
],
\App\Domain\Financial\Events\PaymentPlanDefaulted::class => [],
......
......@@ -21,6 +21,9 @@
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"files": [
"app/Helpers/whatsapp.php"
],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
......
......@@ -41,6 +41,7 @@ public function run(): void
$this->call(FinancialAccountsSeeder::class);
$this->call(RolesAndPermissionsSeeder::class);
$this->call(PermissionSeeder::class);
$this->call(PaymentNotificationTemplateSeeder::class);
// Assign academy_owner role to the admin user
$ownerRole = Role::where('academy_id', $academy->id)
......
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PaymentNotificationTemplateSeeder extends Seeder
{
public function run(): void
{
$now = now();
$templates = [
// Email template
[
'uuid' => Str::uuid()->toString(),
'academy_id' => null,
'event_type' => 'payment.received',
'channel' => 'email',
'subject' => 'تأكيد دفع — {{amount}} ج.م',
'body' => "مرحباً {{participant_name}},\n\nتم تسجيل دفعة بقيمة {{amount}} ج.م بتاريخ {{date}}.\nطريقة الدفع: {{method}}\nرقم الإيصال: {{receipt_number}}\n\nشكراً لك،\nإدارة الأكاديمية",
'variables' => json_encode(['participant_name', 'amount', 'method', 'date', 'receipt_number', 'invoice_number']),
'is_active' => true,
'locale' => 'ar',
'metadata' => '{}',
'created_at' => $now,
'updated_at' => $now,
],
// SMS template
[
'uuid' => Str::uuid()->toString(),
'academy_id' => null,
'event_type' => 'payment.received',
'channel' => 'sms',
'subject' => null,
'body' => 'تم استلام {{amount}} ج.م - إيصال: {{receipt_number}} - {{date}}',
'variables' => json_encode(['amount', 'receipt_number', 'date']),
'is_active' => true,
'locale' => 'ar',
'metadata' => '{}',
'created_at' => $now,
'updated_at' => $now,
],
// In-App template
[
'uuid' => Str::uuid()->toString(),
'academy_id' => null,
'event_type' => 'payment.received',
'channel' => 'in_app',
'subject' => null,
'body' => 'تم تسجيل دفعة {{amount}} ج.م من {{participant_name}}',
'variables' => json_encode(['amount', 'participant_name']),
'is_active' => true,
'locale' => 'ar',
'metadata' => '{}',
'created_at' => $now,
'updated_at' => $now,
],
];
foreach ($templates as $template) {
DB::table('notification_templates')->updateOrInsert(
[
'academy_id' => $template['academy_id'],
'event_type' => $template['event_type'],
'channel' => $template['channel'],
'locale' => $template['locale'],
],
$template,
);
}
$this->command->info('Seeded 3 payment notification templates (email, sms, in_app).');
}
}
......@@ -17,8 +17,10 @@
['label' => 'التعيينات', 'route' => 'assignments.list', 'icon' => 'calendar', 'permission' => 'assignments.list'],
]],
['section' => 'الحضور', 'items' => [
['section' => 'الحضور والجدول', 'items' => [
['label' => 'تسجيل الحضور', 'route' => 'attendance.list', 'icon' => 'clipboard-check', 'permission' => 'attendance.mark'],
['label' => 'الجدول الأسبوعي', 'route' => 'schedule.weekly', 'icon' => 'calendar-days', 'permission' => 'schedules.view'],
['label' => 'لوحة المدرب', 'route' => 'trainer.dashboard', 'icon' => 'user', 'permission' => 'attendance.mark'],
]],
['section' => 'المالية', 'items' => [
......@@ -52,8 +54,9 @@
['label' => 'تعيين المساحات', 'route' => 'facilities.space-assignment', 'icon' => 'grid', 'permission' => 'facilities.manage_layouts'],
]],
['section' => 'الإشعارات', 'items' => [
['section' => 'الإشعارات والرسائل', 'items' => [
['label' => 'مركز الإشعارات', 'route' => 'notifications.center', 'icon' => 'bolt', 'permission' => 'dashboard.view'],
['label' => 'رسائل جماعية', 'route' => 'messaging.bulk', 'icon' => 'chat', 'permission' => 'notifications.manage'],
['label' => 'القوالب', 'route' => 'notifications.templates', 'icon' => 'document-text', 'permission' => 'notifications.manage'],
['label' => 'سجل الإرسال', 'route' => 'notifications.logs', 'icon' => 'eye', 'permission' => 'notifications.manage'],
]],
......@@ -107,6 +110,9 @@
'reception' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>',
'swatch' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"/>',
'grid' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>',
'chat' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>',
'user' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>',
'receipt-percent' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2zM10 8.5a.5.5 0 11-1 0 .5.5 0 011 0zm5 5a.5.5 0 11-1 0 .5.5 0 011 0z"/>',
];
$permissionService = app(\App\Domain\Identity\Services\PermissionService::class);
......
......@@ -12,15 +12,20 @@
</h2>
</div>
<!-- Center: Global Search -->
<div class="hidden md:block flex-1 max-w-md mx-4">
@livewire('global-search')
</div>
<!-- Right side -->
<div class="flex items-center gap-4">
<!-- Branch Switcher -->
@livewire('branch-switcher')
<!-- Notifications bell (placeholder) -->
<button class="relative text-gray-500 hover:text-gray-700">
<!-- Notifications -->
<a href="{{ route('notifications.center') }}" class="relative text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
</button>
</a>
</div>
</div>
</header>
@props(['route', 'size' => 'md'])
@php
$sizeClasses = match($size) {
'sm' => 'px-3 py-2 text-sm gap-1.5',
'lg' => 'px-6 py-3 text-base gap-2.5',
default => 'px-4 py-2.5 text-sm gap-2',
};
@endphp
<button
type="button"
x-data
@click="
const win = window.open('{{ $route }}', '_blank', 'width=350,height=600,scrollbars=yes,resizable=yes');
if (win) {
win.focus();
}
"
{{ $attributes->merge(['class' => "inline-flex items-center justify-center {$sizeClasses} bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"]) }}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/>
</svg>
{{ __('طباعة الإيصال') }}
</button>
......@@ -124,7 +124,7 @@
<!-- Row 4: Alert Badges -->
@if($lowStockCount > 0 || $nearFullGroups > 0 || $recentEnrollments > 0)
<div class="flex flex-wrap gap-4">
<div class="flex flex-wrap gap-4 mb-6">
@if($lowStockCount > 0)
<div class="inline-flex items-center gap-2 bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......@@ -153,4 +153,102 @@
@endif
</div>
@endif
<!-- Row 5: Today's Schedule + Overdue Invoices -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<!-- Today's Schedule -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('جدول اليوم') }}</h3>
@if($todaySchedule->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد جلسات مجدولة اليوم') }}</p>
@else
<div class="space-y-3 max-h-64 overflow-y-auto">
@foreach($todaySchedule as $session)
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div class="text-center min-w-[50px]">
<span class="text-sm font-bold text-blue-600" dir="ltr">{{ $session->start_time?->format('H:i') }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ $session->group?->name_ar ?? '' }}</p>
<p class="text-xs text-gray-500">{{ $session->group?->program?->name_ar ?? '' }}</p>
</div>
<span class="text-xs px-2 py-1 rounded-full
{{ $session->status === 'completed' ? 'bg-green-100 text-green-700' :
($session->status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600') }}">
{{ $session->status === 'completed' ? __('مكتملة') :
($session->status === 'in_progress' ? __('جارية') : __('مجدولة')) }}
</span>
</div>
@endforeach
</div>
@endif
</div>
<!-- Overdue Invoices -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('فواتير متأخرة') }}</h3>
@if($overdueInvoices->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد فواتير متأخرة') }}</p>
@else
<div class="space-y-3 max-h-64 overflow-y-auto">
@foreach($overdueInvoices as $inv)
<a href="{{ route('invoices.show', $inv) }}" class="flex items-center justify-between p-3 bg-red-50 rounded-lg hover:bg-red-100 transition">
<div>
<p class="text-sm font-medium text-gray-800">{{ $inv->participant?->person?->name_ar ?? $inv->invoice_number }}</p>
<p class="text-xs text-red-600">{{ __('استحقاق:') }} {{ $inv->due_date?->format('Y-m-d') }}</p>
</div>
<span class="text-sm font-bold text-red-700" dir="ltr">{{ number_format(($inv->total_amount - $inv->paid_amount) / 100, 2) }} {{ __('ج.م') }}</span>
</a>
@endforeach
</div>
@endif
</div>
</div>
<!-- Row 6: Recent Payments + Upcoming Birthdays -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Recent Payments -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('آخر المدفوعات') }}</h3>
@if($recentPayments->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد مدفوعات حديثة') }}</p>
@else
<div class="space-y-3 max-h-64 overflow-y-auto">
@foreach($recentPayments as $payment)
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-800">{{ $payment->reference ?? '-' }}</p>
<p class="text-xs text-gray-500">{{ $payment->created_at?->diffForHumans() }} — {{ $payment->createdBy?->name ?? '' }}</p>
</div>
<span class="text-sm font-bold text-emerald-600" dir="ltr">{{ number_format($payment->amount / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endforeach
</div>
@endif
</div>
<!-- Upcoming Birthdays -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('أعياد ميلاد قادمة') }}</h3>
@if($upcomingBirthdays->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد أعياد ميلاد قريبة') }}</p>
@else
<div class="space-y-3 max-h-64 overflow-y-auto">
@foreach($upcomingBirthdays as $person)
<div class="flex items-center gap-3 p-3 bg-pink-50 rounded-lg">
<div class="flex-shrink-0 w-8 h-8 bg-pink-200 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15.546c-.523 0-1.046.151-1.5.454a2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0A1.75 1.75 0 003 15.546V12a9 9 0 0118 0v3.546z"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-gray-800">{{ $person->name_ar ?? $person->name }}</p>
<p class="text-xs text-gray-500">{{ $person->date_of_birth?->format('d/m') }}</p>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
<div class="relative" x-data="{ open: @entangle('showResults') }" @click.outside="open = false">
<div class="relative">
<input type="text"
wire:model.live.debounce.300ms="query"
@focus="if($wire.query.length >= 2) open = true"
placeholder="{{ __('بحث عام...') }}"
class="w-full sm:w-64 px-4 py-2 ps-10 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<svg class="absolute start-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<div x-show="open" x-transition
class="absolute top-full mt-2 w-full sm:w-96 bg-white rounded-xl shadow-xl border border-gray-200 z-50 max-h-96 overflow-y-auto">
@if(empty($results))
<div class="p-4 text-center text-gray-400 text-sm">{{ __('لا توجد نتائج') }}</div>
@else
@foreach($results as $result)
<button wire:click="selectResult('{{ $result['url'] }}')"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition text-start border-b border-gray-100 last:border-0">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold
{{ $result['type'] === 'participant' ? 'bg-blue-100 text-blue-600' :
($result['type'] === 'invoice' ? 'bg-green-100 text-green-600' : 'bg-purple-100 text-purple-600') }}">
{{ $result['type'] === 'participant' ? 'م' : ($result['type'] === 'invoice' ? 'ف' : 'مج') }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ $result['label'] }}</p>
<p class="text-xs text-gray-500 truncate">{{ $result['subtitle'] }}</p>
</div>
</button>
@endforeach
@endif
</div>
</div>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ __('الفواتير') }}</h1>
@can('invoices.create')
<a href="{{ route('invoices.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<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 4v16m8-8H4"/></svg>
{{ __('إنشاء فاتورة') }}
</a>
@endcan
<div class="flex items-center gap-2">
<a href="{{ route('export.invoices', ['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-sm font-medium">
<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>
{{ __('تصدير CSV') }}
</a>
@can('invoices.create')
<a href="{{ route('invoices.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<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 4v16m8-8H4"/></svg>
{{ __('إنشاء فاتورة') }}
</a>
@endcan
</div>
</div>
{{-- Flash Messages --}}
......
......@@ -154,6 +154,7 @@ class="text-gray-600 hover:text-gray-800 text-sm font-medium">
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('المبلغ') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('التاريخ') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('إجراءات') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
......@@ -200,6 +201,9 @@ class="text-gray-600 hover:text-gray-800 text-sm font-medium">
{{ $payment->status->label() }}
</span>
</td>
<td class="px-4 py-3 text-center">
<x-ui.print-receipt-button :route="route('receipts.payment.print', $payment->uuid)" size="sm" />
</td>
</tr>
@endforeach
</tbody>
......
This diff is collapsed.
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ __('المشتركين') }}</h1>
@can('participants.create')
<a href="{{ route('participants.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<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 4v16m8-8H4"/></svg>
{{ __('تسجيل مشترك') }}
</a>
@endcan
<div class="flex items-center gap-2">
<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-sm font-medium">
<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>
{{ __('تصدير CSV') }}
</a>
@can('participants.create')
<a href="{{ route('participants.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<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 4v16m8-8H4"/></svg>
{{ __('تسجيل مشترك') }}
</a>
@endcan
</div>
</div>
{{-- Flash Messages --}}
......
......@@ -410,10 +410,9 @@ class="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 te
</div>
</div>
<div class="border-t border-gray-200 p-4 flex gap-3">
<button @click="window.print()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
{{ __('طباعة') }}
</button>
@if($lastTransactionUuid)
<x-ui.print-receipt-button :route="route('receipts.pos.print', $lastTransactionUuid)" class="flex-1" />
@endif
<button wire:click="closeReceipt"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
{{ __('عملية جديدة') }}
......
......@@ -438,6 +438,12 @@ class="inline-flex items-center gap-2 px-8 py-3 min-h-16 bg-green-600 text-white
</div>
</div>
@if($last_payment_uuid)
<div class="flex justify-center mt-4">
<x-ui.print-receipt-button :route="route('receipts.payment.print', $last_payment_uuid)" size="lg" />
</div>
@endif
<div class="flex items-center justify-center gap-4 mt-6">
<a href="{{ route('receptionist.dashboard') }}" wire:navigate
class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-base font-medium transition-colors">
......
......@@ -123,6 +123,55 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2
</div>
</div>
{{-- Duplicate Detection Warning --}}
@if(!empty($potentialDuplicates))
<div class="mt-6 p-4 bg-amber-50 border border-amber-300 rounded-xl">
<div class="flex items-start gap-3 mb-4">
<svg class="w-6 h-6 text-amber-600 shrink-0 mt-0.5" 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>
<h3 class="text-base font-bold text-amber-800">{{ __('تنبيه: تم العثور على سجلات مشابهة') }}</h3>
<p class="text-sm text-amber-700 mt-1">{{ __('قد يكون ولي الأمر مسجلاً بالفعل. يرجى التحقق من السجلات التالية:') }}</p>
</div>
</div>
<div class="space-y-3">
@foreach($potentialDuplicates as $duplicate)
<div class="flex items-center justify-between p-3 bg-white rounded-lg border border-amber-200">
<div>
<p class="font-semibold text-gray-800">{{ $duplicate['name'] }}</p>
<div class="flex items-center gap-3 mt-1 text-sm text-gray-500">
@if($duplicate['phone'])
<span dir="ltr">{{ $duplicate['phone'] }}</span>
@endif
@if($duplicate['has_participant'])
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-medium">
{{ __('مشترك') }}: {{ $duplicate['participant_number'] }}
</span>
@endif
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">
{{ __('تطابق') }}: {{ $duplicate['confidence'] }}%
</span>
</div>
</div>
<button wire:click="useExistingPerson({{ $duplicate['person_id'] }})"
class="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors whitespace-nowrap">
{{ __('استخدام هذا السجل') }}
</button>
</div>
@endforeach
</div>
<div class="mt-4 flex justify-end">
<button wire:click="proceedDespiteDuplicates"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-amber-700 bg-amber-100 border border-amber-300 rounded-lg hover:bg-amber-200 transition-colors">
{{ __('تجاهل والمتابعة كتسجيل جديد') }}
</button>
</div>
</div>
@endif
<div class="flex justify-end mt-8">
<button wire:click="nextStep" wire:loading.attr="disabled"
class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-base font-medium transition-colors disabled:opacity-50">
......
This diff is collapsed.
This diff is collapsed.
......@@ -7,3 +7,12 @@
Schedule::command('invoices:mark-overdue')->dailyAt('01:00');
Schedule::command('sessions:generate-upcoming')->dailyAt('02:00');
Schedule::command('audit:cleanup --days=365')->weekly();
Schedule::command('summary:daily')->dailyAt('07:00');
Schedule::command('notifications:birthdays')->dailyAt('08:00');
Schedule::command('inventory:notify-low-stock')->dailyAt('09:00');
Schedule::command('reminders:expiring-enrollments --days=7')->dailyAt('10:00');
Schedule::command('reminders:expiring-enrollments --days=3')->dailyAt('10:30');
Schedule::command('reminders:installments --days=3')->dailyAt('11:00');
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');
......@@ -7,6 +7,7 @@
use App\Livewire\Receptionist\EnrollExistingWizard;
use App\Livewire\Receptionist\NewRegistrationWizard;
use App\Livewire\Receptionist\ReceptionistDashboard;
use App\Livewire\Trainer\TrainerDashboard;
use App\Livewire\Wizards\SetupWizard;
use App\Livewire\Assignments\AssignmentForm;
use App\Livewire\Assignments\AssignmentList;
......@@ -65,6 +66,13 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Public Routes
|--------------------------------------------------------------------------
*/
Route::get('/health', \App\Http\Controllers\HealthController::class)->name('health');
/*
|--------------------------------------------------------------------------
| Guest Routes
......@@ -207,6 +215,10 @@
Route::get('/facilities/schedule-builder', VisualScheduleBuilder::class)->name('facilities.schedule-builder')
->middleware('permission:schedules.manage');
// Schedule
Route::get('/schedule', \App\Livewire\Schedule\WeeklySchedule::class)->name('schedule.weekly')
->middleware('permission:schedules.view');
// Attendance
Route::get('/attendance', AttendanceList::class)->name('attendance.list')
->middleware('permission:attendance.list');
......@@ -271,6 +283,10 @@
Route::get('/settings', \App\Livewire\Settings\AcademySettings::class)->name('settings.academy')
->middleware('permission:settings.view');
// Messaging
Route::get('/messaging', \App\Livewire\Messaging\BulkMessage::class)->name('messaging.bulk')
->middleware('permission:notifications.manage');
// Notifications
Route::get('/notifications', NotificationCenter::class)->name('notifications.center');
Route::get('/notifications/templates', NotificationTemplateList::class)->name('notifications.templates')
......@@ -318,10 +334,24 @@
Route::get('/admin', SuperAdminPanel::class)->name('admin.panel')
->middleware('permission:super_admin.access');
// Exports
Route::get('/export/participants', [\App\Http\Controllers\ExportController::class, 'participants'])
->name('export.participants')->middleware('permission:participants.list');
Route::get('/export/payments', [\App\Http\Controllers\ExportController::class, 'payments'])
->name('export.payments')->middleware('permission:invoices.list');
Route::get('/export/invoices', [\App\Http\Controllers\ExportController::class, 'invoices'])
->name('export.invoices')->middleware('permission:invoices.list');
Route::get('/export/enrollments', [\App\Http\Controllers\ExportController::class, 'enrollments'])
->name('export.enrollments')->middleware('permission:enrollments.list');
// Audit
Route::get('/audit', AuditLogList::class)->name('audit.list')
->middleware('permission:audit.list');
// Trainer Dashboard
Route::get('/trainer', TrainerDashboard::class)->name('trainer.dashboard')
->middleware('permission:attendance.mark');
// Receptionist Desk
Route::get('/receptionist', ReceptionistDashboard::class)->name('receptionist.dashboard')
->middleware('permission:participants.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