Commit 101a0f35 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Wire all cross-module integrations: events, enforcement, dashboard, scheduled jobs

Money flows:
- Payslip paid → double-entry financial transaction (debit salary expense / credit cash)
- POS sale → inventory movements for tracked products
- Enrollment → auto-invoice via PricingService
- Invoice overdue detection enhanced with balance guard

Operational events (previously empty → now wired):
- AssignmentCreated → generate trainer attendance expectations
- AssignmentEnded → remove future trainer attendance
- FacilityStatusChanged → cancel reservations on maintenance
- PurchaseOrderReceived → create receiving inventory movements
- StockCountCompleted → process count adjustments
- KitAssembled → deduct component stock
- InventoryMovementCreated → check low stock threshold

Business rule enforcement:
- Medical certificate enforcement at attendance time (configurable)
- Group capacity check + auto-waitlist on enrollment
- Participant status blocking (frozen/suspended can't enroll)
- Attendance threshold from settings (not hardcoded)

Dashboard rebuilt with actionable data:
- Today's sessions, trainers present, payments, active participants
- Needs-attention cards (overdue invoices, pending approvals, expired docs)
- Today's schedule with take-attendance buttons
- Recent payments feed + quick action buttons

Scheduled jobs:
- attendance:auto-absent (hourly)
- documents:expire (daily 06:00)
- payments:detect-defaults (daily 08:00)
- attendance:send-alerts (daily 20:00)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0aa21bd4
<?php
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class AutoMarkAbsent extends Command
{
protected $signature = 'attendance:auto-absent';
protected $description = 'تحديد الغياب التلقائي';
public function handle(): int
{
$cutoff = now()->subHours(2);
// Only process today's and yesterday's sessions (don't touch ancient records)
$sessionDateRange = [now()->subDay()->toDateString(), now()->toDateString()];
// Find sessions that ended more than 2 hours ago (today or yesterday)
$expiredSessionIds = TrainingSession::whereIn('status', ['completed', 'in_progress'])
->whereBetween('session_date', $sessionDateRange)
->whereRaw("(session_date || ' ' || end_time)::timestamp < ?", [$cutoff])
->pluck('id');
if ($expiredSessionIds->isEmpty()) {
$this->info('لا توجد سجلات تحتاج تحديث.');
return self::SUCCESS;
}
// Get expected records that need to be marked absent
$records = AttendanceRecord::whereIn('training_session_id', $expiredSessionIds)
->where('status', AttendanceStatus::Expected)
->get();
if ($records->isEmpty()) {
$this->info('لا توجد سجلات تحتاج تحديث.');
return self::SUCCESS;
}
// Use a system user for event dispatch (first admin or fallback)
$systemUser = User::where('email', 'admin@oc-sport.com')->first()
?? User::first();
$count = 0;
foreach ($records as $record) {
$record->update([
'status' => AttendanceStatus::Absent->value,
'is_flagged' => true,
'marked_at' => now(),
'metadata' => json_encode([
'auto_marked' => true,
'marked_reason' => 'auto_absent_after_2h',
]),
]);
// Dispatch event so compensation penalties and notifications fire
if ($systemUser) {
AttendanceMarked::dispatch($record->fresh(), $systemUser);
}
$count++;
}
$this->info("تم تسجيل {$count} سجل كغائب تلقائياً.");
Log::info("AutoMarkAbsent: Marked {$count} records as absent");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Events\PaymentPlanDefaulted;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\PaymentPlan;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DetectPaymentPlanDefaults extends Command
{
protected $signature = 'payments:detect-defaults';
protected $description = 'كشف التخلف عن خطط الدفع';
private const GRACE_DAYS = 7;
private const DEFAULT_THRESHOLD = 2; // missed installments to consider plan defaulted
public function handle(): int
{
$graceCutoff = now()->subDays(self::GRACE_DAYS)->toDateString();
// Find pending installments that are overdue past grace period
$overdueInstallments = Installment::where('status', 'pending')
->where('due_date', '<', $graceCutoff)
->with('paymentPlan')
->get();
if ($overdueInstallments->isEmpty()) {
$this->info('لا توجد أقساط متأخرة تجاوزت فترة السماح.');
return self::SUCCESS;
}
$updatedCount = 0;
$defaultedPlans = [];
foreach ($overdueInstallments as $installment) {
// Mark installment as overdue
if ($installment->status === 'pending') {
$installment->update(['status' => 'overdue']);
$updatedCount++;
}
// Check if the payment plan as a whole has defaulted
$plan = $installment->paymentPlan;
if (!$plan || isset($defaultedPlans[$plan->id])) {
continue;
}
$missedCount = Installment::where('payment_plan_id', $plan->id)
->where('status', 'overdue')
->count();
if ($missedCount >= self::DEFAULT_THRESHOLD && $plan->status !== 'defaulted') {
$plan->update(['status' => 'defaulted']);
$defaultedPlans[$plan->id] = true;
PaymentPlanDefaulted::dispatch($plan);
Log::warning("PaymentPlan #{$plan->id} defaulted: {$missedCount} missed installments");
$this->warn("خطة الدفع #{$plan->id} متعثرة ({$missedCount} أقساط متأخرة)");
}
}
$this->info("تم تحديث {$updatedCount} قسط متأخر. خطط متعثرة: " . count($defaultedPlans));
Log::info("DetectPaymentPlanDefaults: Updated {$updatedCount} installments, " . count($defaultedPlans) . " plans defaulted");
return self::SUCCESS;
}
}
......@@ -8,13 +8,13 @@
class ExpireDocuments extends Command
{
protected $signature = 'documents:expire';
protected $description = 'Mark approved documents past their expiry date as expired';
protected $description = 'انتهاء صلاحية المستندات';
public function handle(DocumentService $service): int
{
$count = $service->checkAndExpireDocuments();
$this->info("Expired {$count} document(s).");
$this->info("تم إنهاء صلاحية {$count} مستند(ات).");
return self::SUCCESS;
}
......
......@@ -3,14 +3,17 @@
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class MarkAutoAbsent extends Command
{
protected $signature = 'attendance:mark-absent';
protected $description = 'Mark expected attendance records as absent after session end + 2 hours';
protected $description = 'تحديد الغياب التلقائي بعد انتهاء الجلسة بساعتين';
public function handle(): int
{
......@@ -29,20 +32,42 @@ public function handle(): int
$allExpiredIds = $expiredSessionIds->merge($inProgressExpired)->unique();
if ($allExpiredIds->isEmpty()) {
$this->info(__('لا توجد جلسات منتهية.'));
$this->info('لا توجد جلسات منتهية.');
return self::SUCCESS;
}
$updated = AttendanceRecord::whereIn('training_session_id', $allExpiredIds)
// Get records individually so we can dispatch events per record
$records = AttendanceRecord::whereIn('training_session_id', $allExpiredIds)
->where('status', AttendanceStatus::Expected)
->update([
->get();
if ($records->isEmpty()) {
$this->info('لا توجد سجلات تحتاج تحديث.');
return self::SUCCESS;
}
$systemUser = User::where('email', 'admin@oc-sport.com')->first()
?? User::first();
$count = 0;
foreach ($records as $record) {
$record->update([
'status' => AttendanceStatus::Absent->value,
'is_flagged' => true,
'marked_at' => now(),
'metadata' => json_encode(['auto_marked' => true, 'marked_reason' => 'auto_absent_after_2h']),
]);
$this->info(__('تم تسجيل :count سجل كغائب.', ['count' => $updated]));
if ($systemUser) {
AttendanceMarked::dispatch($record->fresh(), $systemUser);
}
$count++;
}
$this->info("تم تسجيل {$count} سجل كغائب.");
Log::info("MarkAutoAbsent: Marked {$count} records as absent");
return self::SUCCESS;
}
}
......@@ -19,6 +19,7 @@ public function handle(): int
Invoice::where('status', InvoiceStatus::Sent)
->whereNotNull('due_date')
->where('due_date', '<', now()->toDateString())
->where('due_amount', '>', 0)
->chunkById(100, function ($invoices) use (&$count) {
foreach ($invoices as $invoice) {
$invoice->update(['status' => InvoiceStatus::Overdue]);
......@@ -27,9 +28,7 @@ public function handle(): int
}
});
if ($count > 0) {
$this->info("Marked {$count} invoices as overdue.");
}
$this->info("Marked {$count} invoice(s) as overdue.");
return self::SUCCESS;
}
......
<?php
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Services\SettingsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendAttendanceAlerts extends Command
{
protected $signature = 'attendance:send-alerts';
protected $description = 'إرسال تنبيهات الحضور';
private const DEFAULT_MIN_ATTENDANCE_PERCENT = 75;
private const LOOKBACK_DAYS = 30;
public function handle(SettingsService $settings): int
{
$threshold = (float) ($settings->get('min_attendance_percent', self::DEFAULT_MIN_ATTENDANCE_PERCENT));
$participants = Participant::where('status', 'active')->get();
$alertCount = 0;
foreach ($participants as $participant) {
$rate = $this->calculateRecentRate($participant);
// Skip if insufficient data (null means not enough records)
if ($rate === null) {
continue;
}
if ($rate < $threshold) {
AttendanceThresholdBreached::dispatch($participant, $rate, $threshold);
$alertCount++;
$this->warn("نسبة حضور منخفضة: {$participant->person?->name_ar} ({$rate}%)");
}
}
$this->info("تم إرسال {$alertCount} تنبيه حضور.");
Log::info("SendAttendanceAlerts: Dispatched {$alertCount} threshold breach alerts");
return self::SUCCESS;
}
/**
* Calculate attendance rate for the last 30 days.
* Formula: rate = (present + late + partial) / (total - cancelled - exempt) x 100
* Returns null if insufficient data (less than 5 records).
*/
private function calculateRecentRate(Participant $participant): ?float
{
$since = now()->subDays(self::LOOKBACK_DAYS);
$query = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->whereHas('session', fn ($q) => $q->where('session_date', '>=', $since));
$total = (clone $query)->count();
// Need at least 5 records to be meaningful
if ($total < 5) {
return null;
}
$excluded = (clone $query)->whereIn('status', [
AttendanceStatus::Cancelled,
AttendanceStatus::Exempt,
])->count();
$denominator = $total - $excluded;
if ($denominator <= 0) {
return null;
}
$positive = (clone $query)->whereIn('status', [
AttendanceStatus::Present,
AttendanceStatus::Late,
AttendanceStatus::Partial,
])->count();
return round(($positive / $denominator) * 100, 1);
}
}
......@@ -7,8 +7,10 @@
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Attendance\Events\ParticipantAbsent;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Document\Models\Document;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
......@@ -18,8 +20,14 @@ class AttendanceMarkingService
private const DEFAULT_PARTICIPANT_GRACE_MINUTES = 15;
private const DEFAULT_TRAINER_GRACE_MINUTES = 10;
public function __construct(
private SettingsService $settings,
) {}
public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $checkInTime = null): AttendanceRecord
{
$this->enforceMedicalCertificate($record);
return DB::transaction(function () use ($record, $marker, $checkInTime) {
$checkInTime ??= now();
$session = $record->session;
......@@ -54,6 +62,11 @@ public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $che
public function markStatus(AttendanceRecord $record, AttendanceStatus $status, User $marker, ?string $reason = null): AttendanceRecord
{
// Enforce medical certificate for positive attendance statuses
if (in_array($status, [AttendanceStatus::Present, AttendanceStatus::Late, AttendanceStatus::Partial])) {
$this->enforceMedicalCertificate($record);
}
return DB::transaction(function () use ($record, $status, $marker, $reason) {
$data = [
'status' => $status,
......@@ -177,6 +190,31 @@ private function getGraceMinutes(AttendanceRecord $record): int
return self::DEFAULT_PARTICIPANT_GRACE_MINUTES;
}
private function enforceMedicalCertificate(AttendanceRecord $record): void
{
if ($record->subject_type !== Participant::class) {
return;
}
$blockWithoutMedical = (bool) $this->settings->get('block_attendance_without_medical', false);
if (!$blockWithoutMedical) {
return;
}
$hasValidCert = Document::where('documentable_type', Participant::class)
->where('documentable_id', $record->subject_id)
->where('document_type', 'medical_certificate')
->where('status', 'approved')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>=', now());
})
->exists();
if (!$hasValidCert) {
throw new DomainException('لا يمكن تسجيل الحضور — الشهادة الطبية مطلوبة أو منتهية');
}
}
private function checkThresholds(AttendanceRecord $record): void
{
if ($record->subject_type !== Participant::class) {
......@@ -189,7 +227,7 @@ private function checkThresholds(AttendanceRecord $record): void
}
$rate = $this->calculateRate($participant->id);
$threshold = 75.0; // Default, would be loaded from academy settings
$threshold = (float) $this->settings->get('minAttendancePercent', 75);
if ($rate < $threshold) {
AttendanceThresholdBreached::dispatch($participant, $rate, $threshold);
......
<?php
namespace App\Domain\Facility\Listeners;
use App\Domain\Facility\Events\FacilityStatusChanged;
use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class CancelReservationsOnMaintenance implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(FacilityStatusChanged $event): void
{
try {
// Only act when facility goes to maintenance or closed
if (!in_array($event->newStatus, ['maintenance', 'closed'])) {
return;
}
$facility = $event->facility;
// Cancel all FUTURE confirmed/tentative reservations
$reservations = SpaceReservation::where('facility_id', $facility->id)
->where('reservation_date', '>', today())
->whereIn('status', ['confirmed', 'tentative'])
->get();
foreach ($reservations as $reservation) {
$reservation->update([
'status' => 'cancelled',
'cancelled_by' => $event->actor->id,
'cancelled_at' => now(),
]);
// Notify the creator of the reservation
if ($reservation->created_by) {
$this->notificationService->sendSimple(
type: 'facility.reservation_cancelled',
recipientId: $reservation->created_by,
recipientType: 'user',
data: [
'facility_name' => $facility->name_ar ?? $facility->name,
'reservation_date' => $reservation->reservation_date->format('Y-m-d'),
'reason' => $event->newStatus === 'maintenance' ? 'صيانة المنشأة' : 'إغلاق المنشأة',
],
priority: 'high',
);
}
}
Log::info("CancelReservationsOnMaintenance: cancelled {$reservations->count()} reservations for facility {$facility->id}");
} catch (\Throwable $e) {
Log::error('CancelReservationsOnMaintenance failed: ' . $e->getMessage(), [
'facility_id' => $event->facility->id,
'new_status' => $event->newStatus,
]);
}
}
public function failed(FacilityStatusChanged $event, \Throwable $exception): void
{
Log::critical('CancelReservationsOnMaintenance PERMANENTLY FAILED', [
'facility_id' => $event->facility->id,
'new_status' => $event->newStatus,
'error' => $exception->getMessage(),
]);
}
}
......@@ -13,6 +13,9 @@
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\HR\Models\TrainerCompensation;
use App\Domain\Financial\Enums\TransactionType;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService;
use App\Models\User;
......@@ -335,9 +338,8 @@ public function markPaid(
$this->applyAdvanceDeduction($payslip->trainer_id, $payslip->advances_deducted);
}
// TODO: Create double-entry financial transaction
// Debit: Salary Expense account
// Credit: Cash / Bank account (derived from $paymentMethod)
// Double-entry financial transaction
$this->createPayslipTransaction($payslip, $paymentMethod, $actor);
return $payslip->fresh();
});
......@@ -450,6 +452,54 @@ private function generatePayslipNumber(int $academyId, Carbon $date): string
return "{$prefix}{$month}{$seq}";
}
/**
* Create the double-entry financial transaction for a paid payslip.
* Debit: Salary Expense (5000)
* Credit: Cash (1000) or Bank (1010) based on payment method
*/
private function createPayslipTransaction(Payslip $payslip, string $paymentMethod, User $actor): void
{
$academyId = $payslip->academy_id;
// Resolve salary expense account (code 5000)
$salaryExpenseAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', '5000')
->first();
if (!$salaryExpenseAccount) {
throw new DomainException('حساب مصروف الرواتب غير موجود — يرجى إعداد شجرة الحسابات');
}
// Resolve cash/bank account based on payment method
$creditAccountCode = match ($paymentMethod) {
'cash' => '1000',
'bank_transfer', 'instapay', 'cheque' => '1010',
default => '1000',
};
$creditAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', $creditAccountCode)
->first();
if (!$creditAccount) {
throw new DomainException('حساب النقدية/البنك غير موجود — يرجى إعداد شجرة الحسابات');
}
Transaction::create([
'academy_id' => $academyId,
'debit_account_id' => $salaryExpenseAccount->id,
'credit_account_id' => $creditAccount->id,
'reference_type' => Payslip::class,
'reference_id' => $payslip->id,
'amount' => $payslip->net_amount,
'currency' => 'EGP',
'type' => TransactionType::PaymentMade,
'description' => "راتب: {$payslip->payslip_number}",
'transaction_date' => now()->toDateString(),
'created_by' => $actor->id,
]);
}
/**
* Map a CompensationType value to the corresponding PayslipItemType.
*/
......
<?php
namespace App\Domain\Inventory\Events;
use App\Domain\Inventory\Models\InventoryLevel;
use App\Domain\Inventory\Models\InventoryMovement;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InventoryMovementCreated implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public InventoryMovement $movement,
public InventoryLevel $level,
) {}
}
......@@ -2,6 +2,7 @@
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Events\InventoryMovementCreated;
use App\Domain\Inventory\Events\ProductLowStock;
use App\Domain\Inventory\Models\InventoryLevel;
use Illuminate\Contracts\Queue\ShouldQueue;
......@@ -9,16 +10,19 @@
class CheckLowStockThreshold implements ShouldQueue
{
public function handle($event): void
public function handle(InventoryMovementCreated $event): void
{
try {
// This listener is triggered after any inventory movement
// and checks if the new level is below minimum
if (!isset($event->level) || !$event->level instanceof InventoryLevel) {
$level = $event->level;
// Only check after outbound movements
if ($event->movement->direction !== 'out') {
return;
}
$level = $event->level;
// Load the product relationship if not already loaded
$level->loadMissing(['product', 'warehouse']);
if ($level->min_stock_level === null || $level->min_stock_level <= 0) {
return;
}
......@@ -31,13 +35,16 @@ public function handle($event): void
);
}
} catch (\Throwable $e) {
Log::error('CheckLowStockThreshold failed: ' . $e->getMessage());
Log::error('CheckLowStockThreshold failed: ' . $e->getMessage(), [
'movement_id' => $event->movement->id,
]);
}
}
public function failed($event, \Throwable $exception): void
public function failed(InventoryMovementCreated $event, \Throwable $exception): void
{
Log::critical('CheckLowStockThreshold PERMANENTLY FAILED', [
'movement_id' => $event->movement->id,
'error' => $exception->getMessage(),
]);
}
......
<?php
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\PurchaseOrderReceived;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class CreateReceivingMovements implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
) {}
public function handle(PurchaseOrderReceived $event): void
{
try {
$purchaseOrder = $event->purchaseOrder;
$warehouse = $purchaseOrder->warehouse;
if (!$warehouse) {
Log::warning('CreateReceivingMovements: PO has no warehouse', [
'purchase_order_id' => $purchaseOrder->id,
]);
return;
}
// Load items with products
$items = $purchaseOrder->items()->with('product')->get();
foreach ($items as $item) {
$product = $item->product;
if (!$product) {
Log::warning('CreateReceivingMovements: PO item has no product', [
'purchase_order_item_id' => $item->id,
]);
continue;
}
// Only create movements for tracked products
if (!$product->track_inventory) {
continue;
}
// Use quantity_received if set, otherwise quantity_ordered
$quantity = $item->quantity_received > 0
? $item->quantity_received
: $item->quantity_ordered;
if ($quantity <= 0) {
continue;
}
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::PurchaseReceived,
quantity: $quantity,
actor: $event->actor,
unitCost: $item->unit_cost,
reference: $purchaseOrder,
reason: "استلام أمر شراء #{$purchaseOrder->order_number}",
);
}
} catch (\Throwable $e) {
Log::error('CreateReceivingMovements failed: ' . $e->getMessage(), [
'purchase_order_id' => $event->purchaseOrder->id,
]);
}
}
public function failed(PurchaseOrderReceived $event, \Throwable $exception): void
{
Log::critical('CreateReceivingMovements PERMANENTLY FAILED', [
'purchase_order_id' => $event->purchaseOrder->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\KitAssembled;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class DeductKitComponents implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
) {}
public function handle(KitAssembled $event): void
{
try {
$kit = $event->kit;
$assemblyQuantity = $event->quantity;
// Load kit components with products
$components = $kit->components()->with('product')->get();
if ($components->isEmpty()) {
Log::warning('DeductKitComponents: Kit has no components', [
'kit_id' => $kit->id,
]);
return;
}
// Determine warehouse — use the first product's primary warehouse
// or the academy's default warehouse
$warehouse = $this->resolveWarehouse($kit);
if (!$warehouse) {
Log::error('DeductKitComponents: Cannot determine warehouse for kit', [
'kit_id' => $kit->id,
]);
return;
}
foreach ($components as $component) {
$product = $component->product;
if (!$product || !$product->track_inventory) {
continue;
}
// Total quantity to deduct = component quantity * assembly quantity
$totalQuantity = $component->quantity * $assemblyQuantity;
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::KitAssembly,
quantity: $totalQuantity,
actor: $event->actor,
reference: $kit,
reason: "تجميع طقم: {$kit->name_ar} × {$assemblyQuantity}",
);
}
} catch (\Throwable $e) {
Log::error('DeductKitComponents failed: ' . $e->getMessage(), [
'kit_id' => $event->kit->id,
'quantity' => $event->quantity,
]);
}
}
private function resolveWarehouse($kit): ?Warehouse
{
// Try to find the default/primary warehouse for this academy
return Warehouse::where('academy_id', $kit->academy_id)
->where('is_active', true)
->orderBy('id')
->first();
}
public function failed(KitAssembled $event, \Throwable $exception): void
{
Log::critical('DeductKitComponents PERMANENTLY FAILED', [
'kit_id' => $event->kit->id,
'quantity' => $event->quantity,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\StockCountCompleted;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class ProcessCountAdjustments implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
) {}
public function handle(StockCountCompleted $event): void
{
try {
$stockCount = $event->stockCount;
$warehouse = $stockCount->warehouse;
if (!$warehouse) {
Log::warning('ProcessCountAdjustments: StockCount has no warehouse', [
'stock_count_id' => $stockCount->id,
]);
return;
}
// Load items with products
$items = $stockCount->items()->with('product')->get();
foreach ($items as $item) {
$product = $item->product;
if (!$product) {
continue;
}
// Skip items where count matches system
$discrepancy = $item->counted_quantity - $item->system_quantity;
if ($discrepancy === 0) {
continue;
}
// Determine adjustment type based on discrepancy direction
if ($discrepancy > 0) {
// Counted MORE than system — adjustment up
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::CountAdjustmentUp,
quantity: $discrepancy,
actor: $event->actor,
reference: $stockCount,
reason: "تسوية جرد #{$stockCount->count_number} — زيادة {$discrepancy}",
);
} else {
// Counted LESS than system — adjustment down
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::CountAdjustmentDown,
quantity: abs($discrepancy),
actor: $event->actor,
reference: $stockCount,
reason: "تسوية جرد #{$stockCount->count_number} — نقص " . abs($discrepancy),
);
}
}
} catch (\Throwable $e) {
Log::error('ProcessCountAdjustments failed: ' . $e->getMessage(), [
'stock_count_id' => $event->stockCount->id,
]);
}
}
public function failed(StockCountCompleted $event, \Throwable $exception): void
{
Log::critical('ProcessCountAdjustments PERMANENTLY FAILED', [
'stock_count_id' => $event->stockCount->id,
'error' => $exception->getMessage(),
]);
}
}
......@@ -3,6 +3,7 @@
namespace App\Domain\Inventory\Services;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\InventoryMovementCreated;
use App\Domain\Inventory\Events\ProductLowStock;
use App\Domain\Inventory\Exceptions\InsufficientStockException;
use App\Domain\Inventory\Models\InventoryLevel;
......@@ -109,12 +110,8 @@ public function createMovement(
'created_by' => $actor->id,
]);
// Check low stock threshold after outbound movement
if ($expectedDirection === 'out' && $level->min_stock_level > 0) {
if ($level->quantity_on_hand <= $level->min_stock_level) {
ProductLowStock::dispatch($product, $level, $warehouse);
}
}
// Dispatch movement event for downstream listeners (e.g. low stock check)
InventoryMovementCreated::dispatch($movement, $level);
return $movement;
});
......
......@@ -7,6 +7,10 @@
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\WalletService;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType;
......@@ -28,6 +32,7 @@ public function __construct(
private WalletService $walletService,
private CashSessionService $cashSessionService,
private PlatformFeeService $platformFeeService,
private InventoryService $inventoryService,
) {}
/**
......@@ -171,7 +176,7 @@ public function processTransaction(
$this->recordSinglePayment($invoice, $paymentMethod, $totalAmount, $cashier, $participant);
}
// Handle enrollment for program items
// Handle enrollment for program items + inventory deduction for tracked products
foreach ($cartItems as $item) {
$itemType = $item['item_type'] instanceof POSItemType
? $item['item_type']
......@@ -184,6 +189,17 @@ public function processTransaction(
isset($item['metadata']['group_id']) ? (int) $item['metadata']['group_id'] : null
);
}
// Inventory movement for tracked products
if ($itemType === POSItemType::Product && isset($item['item_id'])) {
$this->deductInventoryIfTracked(
(int) $item['item_id'],
(int) ($item['quantity'] ?? 1),
$branchId,
$posTransaction,
$cashier
);
}
}
// Update cash session totals
......@@ -293,6 +309,40 @@ private function createEnrollmentIfNeeded(Participant $participant, int $program
]);
}
/**
* Deduct inventory for a tracked product after POS sale.
* Finds the branch warehouse and creates a 'sale' movement.
*/
private function deductInventoryIfTracked(int $productId, int $quantity, int $branchId, POSTransaction $posTransaction, User $cashier): void
{
$product = Product::find($productId);
if (!$product || !$product->track_inventory) {
return;
}
// Resolve warehouse for this branch (first active warehouse for the branch)
$warehouse = Warehouse::where('branch_id', $branchId)
->where('is_active', true)
->first();
if (!$warehouse) {
// No warehouse configured for this branch — skip silently
// (admin should configure warehouses for inventory-tracked branches)
return;
}
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::Sale,
quantity: $quantity,
actor: $cashier,
unitCost: $product->cost_price,
reference: $posTransaction,
reason: "بيع نقطة بيع: {$posTransaction->receipt_number}",
);
}
/**
* Generate unique receipt number: RCP-{BRANCH}-{YYYYMMDD}-{SEQ}
*/
......
<?php
namespace App\Domain\Scheduling\Listeners;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Events\AssignmentCreated;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class GenerateTrainerAttendance implements ShouldQueue
{
public function handle(AssignmentCreated $event): void
{
try {
$assignment = $event->assignment;
// Only process active assignments
if (!$assignment->isActive()) {
return;
}
// Determine the group to generate attendance for
$groupId = null;
if ($assignment->assignable_type === TrainingGroup::class) {
$groupId = $assignment->assignable_id;
} elseif ($assignment->assignable_type === TrainingSession::class) {
// Single session assignment — generate for just that session
$session = TrainingSession::find($assignment->assignable_id);
if ($session && $session->session_date >= now()->toDateString()) {
$this->createIfNotExists($session, $assignment->user_id, $session->academy_id);
}
return;
}
if (!$groupId) {
return;
}
// Generate 'expected' records for all FUTURE sessions of the group
$futureSessions = TrainingSession::where('training_group_id', $groupId)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled', 'in_progress'])
->get();
foreach ($futureSessions as $session) {
$this->createIfNotExists($session, $assignment->user_id, $session->academy_id);
}
} catch (\Throwable $e) {
Log::error('GenerateTrainerAttendance failed: ' . $e->getMessage(), [
'assignment_id' => $event->assignment->id,
]);
}
}
private function createIfNotExists(TrainingSession $session, int $userId, int $academyId): void
{
$exists = AttendanceRecord::where('training_session_id', $session->id)
->where('subject_type', User::class)
->where('subject_id', $userId)
->exists();
if ($exists) {
return;
}
AttendanceRecord::create([
'academy_id' => $academyId,
'training_session_id' => $session->id,
'subject_type' => User::class,
'subject_id' => $userId,
'status' => AttendanceStatus::Expected->value,
'is_auto_generated' => true,
]);
}
public function failed(AssignmentCreated $event, \Throwable $exception): void
{
Log::critical('GenerateTrainerAttendance PERMANENTLY FAILED', [
'assignment_id' => $event->assignment->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Scheduling\Listeners;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Events\AssignmentEnded;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class RemoveTrainerAttendance implements ShouldQueue
{
public function handle(AssignmentEnded $event): void
{
try {
$assignment = $event->assignment;
// Determine which sessions to clean up
if ($assignment->assignable_type === TrainingGroup::class) {
$futureSessionIds = TrainingSession::where('training_group_id', $assignment->assignable_id)
->where('session_date', '>', today())
->pluck('id');
} elseif ($assignment->assignable_type === TrainingSession::class) {
$session = TrainingSession::find($assignment->assignable_id);
if (!$session || $session->session_date <= today()) {
return;
}
$futureSessionIds = collect([$session->id]);
} else {
return;
}
if ($futureSessionIds->isEmpty()) {
return;
}
// Delete only 'expected' records for future sessions — don't touch past records
AttendanceRecord::whereIn('training_session_id', $futureSessionIds)
->where('subject_type', User::class)
->where('subject_id', $assignment->user_id)
->where('status', 'expected')
->delete();
} catch (\Throwable $e) {
Log::error('RemoveTrainerAttendance failed: ' . $e->getMessage(), [
'assignment_id' => $event->assignment->id,
]);
}
}
public function failed(AssignmentEnded $event, \Throwable $exception): void
{
Log::critical('RemoveTrainerAttendance PERMANENTLY FAILED', [
'assignment_id' => $event->assignment->id,
'error' => $exception->getMessage(),
]);
}
}
......@@ -2,7 +2,9 @@
namespace App\Domain\Training\Services;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Events\EnrollmentCancelled;
use App\Domain\Training\Events\EnrollmentCompleted;
......@@ -19,13 +21,15 @@ class EnrollmentService
{
public function __construct(
private readonly TrainingGroupService $groupService,
private readonly PricingService $pricingService,
private readonly InvoiceService $invoiceService,
) {}
public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
{
return DB::transaction(function () use ($participant, $group, $actor, $options) {
// Guard: participant status must allow enrollment
$blockedStatuses = ['suspended', 'blacklisted', 'transferred'];
$blockedStatuses = ['frozen', 'suspended', 'blacklisted', 'transferred'];
if (in_array($participant->status->value, $blockedStatuses)) {
throw new DomainException('لا يمكن تسجيل مشترك ' . $this->getStatusLabel($participant->status->value));
}
......@@ -83,6 +87,11 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
// Update group count
$this->groupService->incrementCount($group);
// Auto-create invoice if program has a price (skip if invoice already provided via options)
if (empty($options['invoice_id'])) {
$this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
}
EnrollmentCreated::dispatch($enrollment, $actor);
return $enrollment;
......@@ -326,9 +335,68 @@ public function processWaitlist(TrainingGroup $group): void
}
}
/**
* Auto-create an invoice for a new enrollment if the program has a base price.
* Uses PricingService to calculate the final price (applies rules/discounts).
*/
private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor): void
{
$program = $group->program;
if (!$program) {
return;
}
// Use PricingService to calculate the price — it will throw if no base price exists
try {
$priceResult = $this->pricingService->calculate(
priceable: $program,
participant: $participant,
branchId: $group->branch_id,
);
} catch (DomainException) {
// No base price configured for this program — no invoice needed (free program)
return;
}
// If final amount is 0 (100% discount or free), skip invoice
if ($priceResult->finalAmount <= 0) {
$enrollment->update(['payment_status' => 'paid']);
return;
}
$invoice = $this->invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $group->academy_id,
'branch_id' => $group->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'number' => $this->invoiceService->generateNumber($group->academy_id),
'total_amount' => $priceResult->finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
], [
[
'description' => "اشتراك: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
],
], $actor);
// Link invoice to enrollment
$enrollment->update([
'invoice_id' => $invoice->id,
'payment_status' => 'pending',
]);
}
private function getStatusLabel(string $status): string
{
return match ($status) {
'frozen' => 'متجمد',
'suspended' => 'موقوف',
'blacklisted' => 'محظور',
'transferred' => 'محول',
......
......@@ -4,16 +4,17 @@
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Models\Document;
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\HR\Enums\PayslipStatus;
use App\Domain\HR\Models\Payslip;
use App\Domain\Inventory\Models\InventoryLevel;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Models\POSTransaction;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -42,119 +43,130 @@ public function mount(): void
$this->redirect(route($redirectMap[$roleSlug]));
return;
}
$this->authorize('dashboard.view');
}
public function render()
{
$today = now()->toDateString();
$thisMonth = now()->startOfMonth()->toDateString();
$now = now();
$branchId = $this->getActiveBranchId();
$stats = [
'active_participants' => Participant::where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))->count(),
'active_enrollments' => Enrollment::where('status', 'active')
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))->count(),
'active_groups' => TrainingGroup::whereIn('status', ['active', 'full'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))->count(),
'today_sessions' => TrainingSession::where('session_date', $today)
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))->count(),
];
$canViewFinancials = auth()->user()->hasPermission('invoices.list');
$financial = $canViewFinancials ? [
'revenue_this_month' => Payment::where('status', 'confirmed')
->whereDate('created_at', '>=', $thisMonth)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))->sum('amount'),
'outstanding_invoices' => Invoice::whereIn('status', [
InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue,
])->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))->sum('due_amount'),
'pos_today' => POSTransaction::whereDate('processed_at', $today)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))->sum('total_amount'),
'pos_today_count' => POSTransaction::whereDate('processed_at', $today)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))->count(),
] : null;
$todaySessions = TrainingSession::where('session_date', $today)
// --- Row 1: Today's Overview (4 stat cards) ---
$sessionsToday = TrainingSession::where('session_date', $today)
->whereIn('status', ['scheduled', 'in_progress'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->pluck('id');
->count();
$attendance = [
'expected' => AttendanceRecord::whereIn('training_session_id', $todaySessions)->count(),
'present' => AttendanceRecord::whereIn('training_session_id', $todaySessions)
->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late])->count(),
'absent' => AttendanceRecord::whereIn('training_session_id', $todaySessions)
->whereIn('status', [AttendanceStatus::Absent, AttendanceStatus::NoShow])->count(),
];
$attendance['rate'] = $attendance['expected'] > 0
? round(($attendance['present'] / $attendance['expected']) * 100, 0)
: 0;
$trainersPresent = AttendanceRecord::whereHas('session', fn ($q) => $q->where('session_date', $today))
->where('subject_type', 'trainer')
->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late])
->count();
$lowStockCount = Product::where('track_inventory', true)
->where('is_active', true)
->whereNotNull('min_stock_level')
$paymentsToday = Payment::where('status', 'confirmed')
->whereDate('created_at', $today)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereHas('inventoryLevels', function ($q) {
$q->whereRaw('quantity_on_hand < (SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id)');
})->count();
->sum('amount');
$nearFullGroups = TrainingGroup::where('status', 'active')
->whereRaw('current_count >= max_capacity * 0.9')
->where('max_capacity', '>', 0)
$activeParticipants = Participant::where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->count();
$recentEnrollments = Enrollment::where('status', 'active')
->where('created_at', '>=', now()->subDays(7))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
// --- Row 2: Needs Attention ---
$overdueInvoicesCount = Invoice::where('status', InvoiceStatus::Overdue)
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->count();
$overdueInvoices = $canViewFinancials ? Invoice::where('status', InvoiceStatus::Overdue)
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->with('billable')
->orderBy('due_date')
->limit(5)
->get() : collect();
$pendingPayslips = Payslip::whereIn('status', [PayslipStatus::Draft, PayslipStatus::PendingApproval])
->count();
$pendingDocuments = Document::where('status', DocumentStatus::Pending)
->count();
$lowStockItems = InventoryLevel::whereHas('product', fn ($q) => $q->where('track_inventory', true)->where('is_active', true))
->whereRaw('quantity_on_hand <= COALESCE((SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id), 0)')
->whereHas('product', fn ($q) => $q->whereNotNull('min_stock_level'))
->count();
$expiringMedicalCerts = Document::where('document_type', DocumentType::MedicalCertificate)
->where('status', DocumentStatus::Approved)
->whereNotNull('expires_at')
->where('expires_at', '<=', now()->addDays(7))
->where('expires_at', '>=', $today)
->count();
// Sessions that ended but attendance not taken
$endedSessionIds = TrainingSession::where('session_date', $today)
->where('end_time', '<', $now->format('H:i:s'))
->whereIn('status', ['scheduled', 'in_progress', 'completed'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->pluck('id');
$attendanceNotTaken = 0;
if ($endedSessionIds->isNotEmpty()) {
$attendanceNotTaken = $endedSessionIds->count() - TrainingSession::whereIn('id', $endedSessionIds)
->whereHas('attendanceRecords', fn ($q) => $q->where('status', '!=', AttendanceStatus::Expected))
->count();
$attendanceNotTaken = max(0, $attendanceNotTaken);
}
// --- Row 3 Left: Today's Schedule ---
$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'])
->with(['group.program', 'trainer', 'facility'])
->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('participant', 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();
->get()
->map(function ($session) use ($now, $endedSessionIds) {
$session->has_attendance = false;
if ($endedSessionIds->contains($session->id)) {
$session->has_attendance = $session->attendanceRecords()
->where('status', '!=', AttendanceStatus::Expected)
->exists();
}
// Calculate time remaining for upcoming sessions
$session->time_remaining = null;
if ($session->status->value === 'scheduled' && $session->start_time) {
$startDateTime = \Carbon\Carbon::parse($session->session_date->format('Y-m-d') . ' ' . $session->start_time);
if ($startDateTime->isAfter($now)) {
$diffMinutes = (int) $now->diffInMinutes($startDateTime);
if ($diffMinutes < 60) {
$session->time_remaining = $diffMinutes . ' ' . __('دقيقة');
} else {
$hours = intdiv($diffMinutes, 60);
$session->time_remaining = $hours . ' ' . __('ساعة');
}
}
}
return $session;
});
// --- Row 3 Right: Recent Payments ---
$canViewFinancials = auth()->user()->hasPermission('invoices.list');
$recentPayments = $canViewFinancials ? Payment::where('status', 'confirmed')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('creator')
->with(['invoice.billable', 'payer'])
->orderByDesc('created_at')
->limit(5)
->limit(10)
->get() : collect();
return view('livewire.dashboard', [
'stats' => $stats,
'financial' => $financial,
'attendance' => $attendance,
'lowStockCount' => $lowStockCount,
'nearFullGroups' => $nearFullGroups,
'recentEnrollments' => $recentEnrollments,
'overdueInvoices' => $overdueInvoices,
// Row 1
'sessionsToday' => $sessionsToday,
'trainersPresent' => $trainersPresent,
'paymentsToday' => $paymentsToday,
'activeParticipants' => $activeParticipants,
// Row 2
'overdueInvoicesCount' => $overdueInvoicesCount,
'pendingPayslips' => $pendingPayslips,
'pendingDocuments' => $pendingDocuments,
'lowStockItems' => $lowStockItems,
'expiringMedicalCerts' => $expiringMedicalCerts,
'attendanceNotTaken' => $attendanceNotTaken,
// Row 3
'todaySchedule' => $todaySchedule,
'upcomingBirthdays' => $upcomingBirthdays,
'recentPayments' => $recentPayments,
'canViewFinancials' => $canViewFinancials,
]);
}
}
......@@ -95,19 +95,34 @@ class EventServiceProvider extends ServiceProvider
// Facility Events
\App\Domain\Facility\Events\SpaceReservationCreated::class => [],
\App\Domain\Facility\Events\FacilityStatusChanged::class => [],
\App\Domain\Facility\Events\FacilityStatusChanged::class => [
\App\Domain\Facility\Listeners\CancelReservationsOnMaintenance::class,
],
// Inventory Events
\App\Domain\Inventory\Events\ProductLowStock::class => [
\App\Domain\Inventory\Listeners\NotifyAdminLowStock::class,
],
\App\Domain\Inventory\Events\PurchaseOrderReceived::class => [],
\App\Domain\Inventory\Events\StockCountCompleted::class => [],
\App\Domain\Inventory\Events\KitAssembled::class => [],
\App\Domain\Inventory\Events\InventoryMovementCreated::class => [
\App\Domain\Inventory\Listeners\CheckLowStockThreshold::class,
],
\App\Domain\Inventory\Events\PurchaseOrderReceived::class => [
\App\Domain\Inventory\Listeners\CreateReceivingMovements::class,
],
\App\Domain\Inventory\Events\StockCountCompleted::class => [
\App\Domain\Inventory\Listeners\ProcessCountAdjustments::class,
],
\App\Domain\Inventory\Events\KitAssembled::class => [
\App\Domain\Inventory\Listeners\DeductKitComponents::class,
],
// Scheduling Events
\App\Domain\Scheduling\Events\AssignmentCreated::class => [],
\App\Domain\Scheduling\Events\AssignmentEnded::class => [],
\App\Domain\Scheduling\Events\AssignmentCreated::class => [
\App\Domain\Scheduling\Listeners\GenerateTrainerAttendance::class,
],
\App\Domain\Scheduling\Events\AssignmentEnded::class => [
\App\Domain\Scheduling\Listeners\RemoveTrainerAttendance::class,
],
// Participant Events
\App\Domain\Participant\Events\ParticipantSuspended::class => [
......
<div>
<div class="mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('لوحة التحكم') }}</h1>
<p class="text-gray-500 text-sm mt-1">{{ __('مرحباً') }} {{ auth()->user()->name_ar ?? auth()->user()->name }}</p>
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ __('لوحة التحكم') }}</h1>
<p class="text-gray-500 text-sm mt-1">{{ now()->translatedFormat('l j F Y') }} &mdash; {{ __('مرحبا') }}، {{ auth()->user()->name_ar ?? auth()->user()->name }}</p>
</div>
<!-- Row 1: Key Stats — 2-col on mobile, 4-col on desktop -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6">
<!-- Active Participants -->
<div class="bg-white rounded-xl shadow-sm border border-green-200 p-3 sm:p-5">
<div class="flex items-center gap-2 sm:gap-3">
<div class="shrink-0 w-9 h-9 sm:w-12 sm:h-12 bg-green-50 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
<!-- Row 1: Today's Overview (4 stat cards) — auto-refresh every 30s -->
<div wire:poll.30s class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Sessions Today -->
<div class="bg-white rounded-xl shadow-sm border border-blue-200 p-4 sm:p-5">
<div class="flex items-center gap-3">
<div class="shrink-0 w-11 h-11 bg-blue-50 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xl sm:text-3xl font-bold text-green-600" dir="ltr">{{ number_format($stats['active_participants']) }}</p>
<p class="text-xs sm:text-sm text-gray-500 truncate">{{ __('المشتركون النشطون') }}</p>
<p class="text-2xl sm:text-3xl font-bold text-blue-600" dir="ltr">{{ number_format($sessionsToday) }}</p>
<p class="text-xs sm:text-sm text-gray-500">{{ __('جلسات اليوم') }}</p>
</div>
</div>
</div>
<!-- Active Enrollments -->
<div class="bg-white rounded-xl shadow-sm border border-blue-200 p-3 sm:p-5">
<div class="flex items-center gap-2 sm:gap-3">
<div class="shrink-0 w-9 h-9 sm:w-12 sm:h-12 bg-blue-50 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
<!-- Trainers Present -->
<div class="bg-white rounded-xl shadow-sm border border-green-200 p-4 sm:p-5">
<div class="flex items-center gap-3">
<div class="shrink-0 w-11 h-11 bg-green-50 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xl sm:text-3xl font-bold text-blue-600" dir="ltr">{{ number_format($stats['active_enrollments']) }}</p>
<p class="text-xs sm:text-sm text-gray-500 truncate">{{ __('التسجيلات النشطة') }}</p>
<p class="text-2xl sm:text-3xl font-bold text-green-600" dir="ltr">{{ number_format($trainersPresent) }}</p>
<p class="text-xs sm:text-sm text-gray-500">{{ __('مدربون حاضرون') }}</p>
</div>
</div>
</div>
<!-- Active Groups -->
<div class="bg-white rounded-xl shadow-sm border border-purple-200 p-3 sm:p-5">
<div class="flex items-center gap-2 sm:gap-3">
<div class="shrink-0 w-9 h-9 sm:w-12 sm:h-12 bg-purple-50 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
<!-- Payments Today -->
<div class="bg-white rounded-xl shadow-sm border border-emerald-200 p-4 sm:p-5">
<div class="flex items-center gap-3">
<div class="shrink-0 w-11 h-11 bg-emerald-50 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xl sm:text-3xl font-bold text-purple-600" dir="ltr">{{ number_format($stats['active_groups']) }}</p>
<p class="text-xs sm:text-sm text-gray-500 truncate">{{ __('المجموعات النشطة') }}</p>
<p class="text-2xl sm:text-3xl font-bold text-emerald-600" dir="ltr">{{ number_format($paymentsToday / 100, 2) }} <span class="text-sm">{{ __('ج.م') }}</span></p>
<p class="text-xs sm:text-sm text-gray-500">{{ __('تحصيلات اليوم') }}</p>
</div>
</div>
</div>
<!-- Today Sessions -->
<div class="bg-white rounded-xl shadow-sm border border-amber-200 p-3 sm:p-5">
<div class="flex items-center gap-2 sm:gap-3">
<div class="shrink-0 w-9 h-9 sm:w-12 sm:h-12 bg-amber-50 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
<!-- Active Participants -->
<div class="bg-white rounded-xl shadow-sm border border-purple-200 p-4 sm:p-5">
<div class="flex items-center gap-3">
<div class="shrink-0 w-11 h-11 bg-purple-50 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xl sm:text-3xl font-bold text-amber-600" dir="ltr">{{ number_format($stats['today_sessions']) }}</p>
<p class="text-xs sm:text-sm text-gray-500 truncate">{{ __('جلسات اليوم') }}</p>
<p class="text-2xl sm:text-3xl font-bold text-purple-600" dir="ltr">{{ number_format($activeParticipants) }}</p>
<p class="text-xs sm:text-sm text-gray-500">{{ __('مشتركون نشطون') }}</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Financial Summary (owner/admin only) -->
@can('invoices.list')
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 mb-4 sm:mb-6">
<!-- Revenue This Month -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<div class="flex items-center gap-3 mb-2 sm:mb-3">
<div class="shrink-0 w-9 h-9 sm:w-10 sm:h-10 bg-emerald-50 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 sm:w-5 sm:h-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<!-- Row 2: Needs Attention (only show if any count > 0) -->
@if($overdueInvoicesCount > 0 || $pendingPayslips > 0 || $pendingDocuments > 0 || $lowStockItems > 0 || $expiringMedicalCerts > 0 || $attendanceNotTaken > 0)
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('يتطلب انتباهك') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@if($overdueInvoicesCount > 0)
<a href="{{ route('invoices.list', ['status' => 'overdue']) }}" class="group flex items-center gap-3 bg-white rounded-xl border-s-4 border-red-500 border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div class="shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<span class="text-lg font-bold text-red-600" dir="ltr">{{ $overdueInvoicesCount }}</span>
</div>
<h3 class="text-xs sm:text-sm font-medium text-gray-500">{{ __('إيرادات الشهر') }}</h3>
</div>
<p class="text-lg sm:text-2xl font-bold text-gray-800" dir="ltr">{{ number_format($financial['revenue_this_month'] / 100, 2) }} {{ __('ج.م') }}</p>
<p class="text-xs sm:text-sm text-gray-500 mt-1 sm:mt-2">
{{ __('مبيعات اليوم:') }}
<span dir="ltr" class="font-medium">{{ number_format($financial['pos_today_count']) }}</span>
{{ __('عملية بقيمة') }}
<span dir="ltr" class="font-medium">{{ number_format($financial['pos_today'] / 100, 2) }} {{ __('ج.م') }}</span>
</p>
</div>
<!-- Outstanding Invoices -->
<div class="bg-white rounded-xl shadow-sm border {{ $financial['outstanding_invoices'] > 0 ? 'border-red-200' : 'border-gray-200' }} p-4 sm:p-6">
<div class="flex items-center gap-3 mb-2 sm:mb-3">
<div class="shrink-0 w-9 h-9 sm:w-10 sm:h-10 {{ $financial['outstanding_invoices'] > 0 ? 'bg-red-50' : 'bg-gray-50' }} rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 sm:w-5 sm:h-5 {{ $financial['outstanding_invoices'] > 0 ? 'text-red-600' : 'text-gray-600' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800">{{ __('فواتير متأخرة') }}</p>
<p class="text-xs text-gray-500">{{ __('تحتاج متابعة التحصيل') }}</p>
</div>
<h3 class="text-xs sm:text-sm font-medium text-gray-500">{{ __('الفواتير المعلقة') }}</h3>
</div>
<p class="text-lg sm:text-2xl font-bold {{ $financial['outstanding_invoices'] > 0 ? 'text-red-600' : 'text-gray-800' }}" dir="ltr">{{ number_format($financial['outstanding_invoices'] / 100, 2) }} {{ __('ج.م') }}</p>
</div>
</div>
@endcan
<!-- Row 3: Attendance Summary -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-4 sm:mb-6">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<h3 class="text-base sm:text-lg font-semibold text-gray-800">{{ __('حضور اليوم') }}</h3>
<span class="text-xl sm:text-2xl font-bold {{ $attendance['rate'] >= 90 ? 'text-green-600' : ($attendance['rate'] >= 75 ? 'text-amber-600' : 'text-red-600') }}" dir="ltr">{{ $attendance['rate'] }}%</span>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
@endif
<!-- Progress bar -->
<div class="w-full bg-gray-200 rounded-full h-3 sm:h-4 mb-3 sm:mb-4">
<div class="h-3 sm:h-4 rounded-full transition-all duration-300 {{ $attendance['rate'] >= 90 ? 'bg-green-500' : ($attendance['rate'] >= 75 ? 'bg-amber-500' : 'bg-red-500') }}"
style="width: {{ $attendance['rate'] }}%"></div>
</div>
@if($pendingPayslips > 0)
<a href="{{ route('payroll.dashboard') }}" class="group flex items-center gap-3 bg-white rounded-xl border-s-4 border-amber-500 border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div class="shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<span class="text-lg font-bold text-amber-600" dir="ltr">{{ $pendingPayslips }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800">{{ __('رواتب بانتظار الموافقة') }}</p>
<p class="text-xs text-gray-500">{{ __('مسودات أو بانتظار اعتماد') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-amber-500 transition-colors shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
@endif
<div class="flex flex-wrap items-center gap-3 sm:gap-6 text-xs sm:text-sm text-gray-600">
<span>{{ __('الحاضرون:') }} <span dir="ltr" class="font-medium">{{ number_format($attendance['present']) }}</span> {{ __('من') }} <span dir="ltr" class="font-medium">{{ number_format($attendance['expected']) }}</span></span>
<span class="text-gray-300 hidden sm:inline">|</span>
<span>{{ __('الغائبون:') }} <span dir="ltr" class="font-medium text-red-600">{{ number_format($attendance['absent']) }}</span></span>
</div>
</div>
@if($pendingDocuments > 0)
<a href="{{ route('documents.approvals') }}" class="group flex items-center gap-3 bg-white rounded-xl border-s-4 border-amber-500 border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div class="shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<span class="text-lg font-bold text-amber-600" dir="ltr">{{ $pendingDocuments }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800">{{ __('مستندات بانتظار المراجعة') }}</p>
<p class="text-xs text-gray-500">{{ __('تحتاج موافقة أو رفض') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-amber-500 transition-colors shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
@endif
<!-- Row 4: Alert Badges -->
@if($lowStockCount > 0 || $nearFullGroups > 0 || $recentEnrollments > 0)
<div class="flex flex-wrap gap-2 sm:gap-4 mb-4 sm:mb-6">
@if($lowStockCount > 0)
<div class="inline-flex items-center gap-2 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-lg text-xs sm:text-sm font-medium">
<svg class="w-4 h-4 shrink-0" 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>
<span dir="ltr" class="font-bold">{{ $lowStockCount }}</span> {{ __('منتج تحت الحد الأدنى') }}
</div>
@endif
@if($lowStockItems > 0)
<a href="{{ route('inventory.products') }}" class="group flex items-center gap-3 bg-white rounded-xl border-s-4 border-amber-500 border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div class="shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<span class="text-lg font-bold text-amber-600" dir="ltr">{{ $lowStockItems }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800">{{ __('منتجات تحت الحد الأدنى') }}</p>
<p class="text-xs text-gray-500">{{ __('يجب إعادة الطلب') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-amber-500 transition-colors shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
@endif
@if($nearFullGroups > 0)
<div class="inline-flex items-center gap-2 bg-amber-50 border border-amber-200 text-amber-700 px-3 py-2 rounded-lg text-xs sm:text-sm font-medium">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span dir="ltr" class="font-bold">{{ $nearFullGroups }}</span> {{ __('مجموعة شبه ممتلئة') }}
</div>
@endif
@if($expiringMedicalCerts > 0)
<a href="{{ route('documents.approvals') }}" class="group flex items-center gap-3 bg-white rounded-xl border-s-4 border-red-500 border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div class="shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<span class="text-lg font-bold text-red-600" dir="ltr">{{ $expiringMedicalCerts }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800">{{ __('شهادات طبية تنتهي قريبا') }}</p>
<p class="text-xs text-gray-500">{{ __('خلال ٧ أيام') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
@endif
@if($recentEnrollments > 0)
<div class="inline-flex items-center gap-2 bg-green-50 border border-green-200 text-green-700 px-3 py-2 rounded-lg text-xs sm:text-sm font-medium">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
<span dir="ltr" class="font-bold">{{ $recentEnrollments }}</span> {{ __('تسجيل جديد (٧ أيام)') }}
@if($attendanceNotTaken > 0)
<a href="{{ route('attendance.list') }}" class="group flex items-center gap-3 bg-white rounded-xl border-s-4 border-amber-500 border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div class="shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<span class="text-lg font-bold text-amber-600" dir="ltr">{{ $attendanceNotTaken }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800">{{ __('جلسات لم يسجل حضورها') }}</p>
<p class="text-xs text-gray-500">{{ __('انتهت ولم يؤخذ الحضور') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-amber-500 transition-colors shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
@endif
</div>
@endif
</div>
@endif
<!-- Row 5: Today's Schedule + Overdue Invoices -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 sm:gap-4 mb-4 sm:mb-6">
<!-- Today's Schedule -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base sm:text-lg font-semibold text-gray-800 mb-3 sm:mb-4">{{ __('جدول اليوم') }}</h3>
<!-- Row 3: Two columns — Today's Schedule + Recent Payments -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<!-- Left: Today's Schedule -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">{{ __('جدول اليوم') }}</h3>
<a href="{{ route('schedule.weekly') }}" class="text-sm text-blue-600 hover:text-blue-800">{{ __('عرض الكل') }}</a>
</div>
@if($todaySchedule->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد جلسات مجدولة اليوم') }}</p>
<div class="text-center py-8">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-gray-400 text-sm">{{ __('لا توجد جلسات مجدولة اليوم') }}</p>
</div>
@else
<div class="space-y-2 sm:space-y-3 max-h-64 overflow-y-auto">
<div class="space-y-2 max-h-[400px] overflow-y-auto">
@foreach($todaySchedule as $session)
<div class="flex items-center gap-3 p-2.5 sm:p-3 bg-gray-50 rounded-lg">
<div class="text-center min-w-[44px]">
<span class="text-xs sm:text-sm font-bold text-blue-600" dir="ltr">{{ substr($session->start_time, 0, 5) }}</span>
<div class="flex items-center gap-3 p-3 rounded-lg {{ $session->status->value === 'in_progress' ? 'bg-green-50 border border-green-200' : 'bg-gray-50' }}">
<!-- Time -->
<div class="text-center min-w-[50px]">
<span class="text-sm font-bold {{ $session->status->value === 'in_progress' ? 'text-green-600' : 'text-blue-600' }}" dir="ltr">{{ substr($session->start_time, 0, 5) }}</span>
<p class="text-xs text-gray-400" dir="ltr">{{ substr($session->end_time, 0, 5) }}</p>
</div>
<!-- Separator -->
<div class="w-px h-10 {{ $session->status->value === 'in_progress' ? 'bg-green-300' : 'bg-gray-300' }}"></div>
<!-- Details -->
<div class="flex-1 min-w-0">
<p class="text-xs sm:text-sm font-medium text-gray-800 truncate">{{ $session->group?->name_ar ?? '' }}</p>
<p class="text-xs text-gray-500 truncate">{{ $session->group?->program?->name_ar ?? '' }}</p>
<p class="text-sm font-medium text-gray-800 truncate">{{ $session->group?->name_ar ?? $session->group?->name ?? '-' }}</p>
<div class="flex items-center gap-2 text-xs text-gray-500 mt-0.5">
@if($session->trainer)
<span>{{ $session->trainer->name_ar ?? $session->trainer->name }}</span>
@endif
@if($session->facility)
<span class="text-gray-300">|</span>
<span>{{ $session->facility->name_ar ?? $session->facility->name }}</span>
@endif
</div>
</div>
<span class="text-xs px-2 py-1 rounded-full whitespace-nowrap
{{ $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 -->
@can('invoices.list')
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base sm:text-lg font-semibold text-gray-800 mb-3 sm:mb-4">{{ __('فواتير متأخرة') }}</h3>
@if($overdueInvoices->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد فواتير متأخرة') }}</p>
@else
<div class="space-y-2 sm: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-2.5 sm:p-3 bg-red-50 rounded-lg hover:bg-red-100 transition-colors">
<div class="min-w-0 flex-1 me-2">
<p class="text-xs sm:text-sm font-medium text-gray-800 truncate">{{ $inv->contact_name ?? $inv->number }}</p>
<p class="text-xs text-red-600">{{ __('استحقاق:') }} {{ $inv->due_date?->format('Y-m-d') }}</p>
<!-- Status / Action -->
<div class="shrink-0 text-end">
@if($session->status->value === 'in_progress')
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-100 px-2 py-1 rounded-full">
<span class="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></span>
{{ __('جارية') }}
</span>
@elseif($session->status->value === 'completed')
@if(!$session->has_attendance)
<a href="{{ route('attendance.take', $session) }}" class="inline-flex items-center text-xs font-medium text-amber-700 bg-amber-100 px-2 py-1 rounded-full hover:bg-amber-200 transition-colors">
{{ __('سجل الحضور') }}
</a>
@else
<span class="text-xs text-green-600 font-medium">{{ __('مكتملة') }}</span>
@endif
@elseif($session->time_remaining)
<span class="text-xs text-gray-500">{{ __('بعد') }} {{ $session->time_remaining }}</span>
@else
<span class="text-xs text-gray-400">{{ __('مجدولة') }}</span>
@endif
</div>
<span class="text-xs sm:text-sm font-bold text-red-700 whitespace-nowrap" dir="ltr">{{ number_format(($inv->total_amount - $inv->paid_amount) / 100, 2) }} {{ __('ج.م') }}</span>
</a>
</div>
@endforeach
</div>
@endif
</div>
@endcan
</div>
<!-- Row 6: Recent Payments + Upcoming Birthdays -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 sm:gap-4">
<!-- Recent Payments -->
@can('invoices.list')
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base sm:text-lg font-semibold text-gray-800 mb-3 sm:mb-4">{{ __('آخر المدفوعات') }}</h3>
<!-- Right: Recent Payments -->
@if($canViewFinancials)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">{{ __('آخر المدفوعات') }}</h3>
<a href="{{ route('invoices.list') }}" class="text-sm text-blue-600 hover:text-blue-800">{{ __('عرض الكل') }}</a>
</div>
@if($recentPayments->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد مدفوعات حديثة') }}</p>
<div class="text-center py-8">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<p class="text-gray-400 text-sm">{{ __('لا توجد مدفوعات اليوم') }}</p>
</div>
@else
<div class="space-y-2 sm:space-y-3 max-h-64 overflow-y-auto">
<div class="space-y-2 max-h-[400px] overflow-y-auto">
@foreach($recentPayments as $payment)
<div class="flex items-center justify-between p-2.5 sm:p-3 bg-gray-50 rounded-lg">
<div class="min-w-0 flex-1 me-2">
<p class="text-xs sm:text-sm font-medium text-gray-800 truncate">{{ $payment->reference ?? '-' }}</p>
<p class="text-xs text-gray-500 truncate">{{ $payment->created_at?->diffForHumans() }} — {{ $payment->creator?->name ?? '' }}</p>
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg {{ $payment->invoice ? 'cursor-pointer hover:bg-gray-100' : '' }} transition-colors" @if($payment->invoice) onclick="window.location='{{ route('invoices.show', $payment->invoice) }}'" @endif>
<!-- Payment method icon -->
<div class="shrink-0 w-9 h-9 rounded-full flex items-center justify-center
{{ $payment->method->value === 'cash' ? 'bg-green-100' : ($payment->method->value === 'card' ? 'bg-blue-100' : 'bg-purple-100') }}">
@if($payment->method->value === 'cash')
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
@elseif($payment->method->value === 'card')
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
@else
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
@endif
</div>
<span class="text-xs sm:text-sm font-bold text-emerald-600 whitespace-nowrap" dir="ltr">{{ number_format($payment->amount / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endforeach
</div>
@endif
</div>
@endcan
<!-- Upcoming Birthdays -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base sm:text-lg font-semibold text-gray-800 mb-3 sm:mb-4">{{ __('أعياد ميلاد قادمة') }}</h3>
@if($upcomingBirthdays->isEmpty())
<p class="text-gray-400 text-sm">{{ __('لا توجد أعياد ميلاد قريبة') }}</p>
@else
<div class="space-y-2 sm:space-y-3 max-h-64 overflow-y-auto">
@foreach($upcomingBirthdays as $person)
<div class="flex items-center gap-3 p-2.5 sm:p-3 bg-pink-50 rounded-lg">
<div class="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>
<!-- Participant / details -->
<div class="flex-1 min-w-0">
<p class="text-xs sm:text-sm font-medium text-gray-800 truncate">{{ $person->name_ar ?? $person->name }}</p>
<p class="text-xs text-gray-500">{{ $person->date_of_birth?->format('d/m') }}</p>
<p class="text-sm font-medium text-gray-800 truncate">
@if($payment->payer)
{{ $payment->payer->name_ar ?? $payment->payer->name ?? $payment->reference }}
@elseif($payment->invoice?->billable)
{{ $payment->invoice->billable->person?->name_ar ?? $payment->invoice->contact_name ?? $payment->reference }}
@else
{{ $payment->reference ?? '-' }}
@endif
</p>
<p class="text-xs text-gray-500">{{ $payment->created_at?->diffForHumans() }}</p>
</div>
<!-- Amount -->
<span class="text-sm font-bold text-emerald-600 whitespace-nowrap" dir="ltr">{{ number_format($payment->amount / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endforeach
</div>
@endif
</div>
@else
{{-- Placeholder for non-financial users --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('الحضور اليوم') }}</h3>
<div class="text-center py-8">
<p class="text-4xl font-bold text-blue-600 mb-2" dir="ltr">{{ $trainersPresent }}</p>
<p class="text-gray-500 text-sm">{{ __('مدرب حاضر حتى الآن') }}</p>
</div>
</div>
@endif
</div>
<!-- Row 4: Quick Actions Grid -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('إجراءات سريعة') }}</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
@can('participants.create')
<a href="{{ route('receptionist.new-registration') }}" class="flex flex-col items-center gap-3 bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all group">
<div class="w-14 h-14 bg-blue-50 rounded-xl flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<svg class="w-7 h-7 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 text-center">{{ __('تسجيل لاعب جديد') }}</span>
</a>
@endcan
@can('attendance.mark')
<a href="{{ route('attendance.quick') }}" class="flex flex-col items-center gap-3 bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-green-300 transition-all group">
<div class="w-14 h-14 bg-green-50 rounded-xl flex items-center justify-center group-hover:bg-green-100 transition-colors">
<svg class="w-7 h-7 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 text-center">{{ __('تسجيل حضور') }}</span>
</a>
@endcan
@can('invoices.create')
<a href="{{ route('receptionist.collect-payment') }}" class="flex flex-col items-center gap-3 bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-emerald-300 transition-all group">
<div class="w-14 h-14 bg-emerald-50 rounded-xl flex items-center justify-center group-hover:bg-emerald-100 transition-colors">
<svg class="w-7 h-7 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 text-center">{{ __('تحصيل دفعة') }}</span>
</a>
@endcan
@can('invoices.list')
<a href="{{ route('cash-sessions.manage') }}" class="flex flex-col items-center gap-3 bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-amber-300 transition-all group">
<div class="w-14 h-14 bg-amber-50 rounded-xl flex items-center justify-center group-hover:bg-amber-100 transition-colors">
<svg class="w-7 h-7 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 text-center">{{ __('فتح وردية') }}</span>
</a>
@endcan
</div>
</div>
</div>
......@@ -2,13 +2,16 @@
use Illuminate\Support\Facades\Schedule;
Schedule::command('attendance:mark-absent')->hourly();
Schedule::command('attendance:auto-absent')->hourly();
Schedule::command('attendance:enforce-thresholds')->dailyAt('06:00');
Schedule::command('attendance:send-alerts')->dailyAt('20:00');
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('documents:expire')->dailyAt('06:00');
Schedule::command('summary:daily')->dailyAt('07:00');
Schedule::command('notifications:birthdays')->dailyAt('08:00');
Schedule::command('payments:detect-defaults')->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');
......@@ -18,7 +21,6 @@
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');
Schedule::command('documents:expire')->dailyAt('00:15');
Schedule::command('financials:reconcile')->weeklyOn(0, '04:00');
Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00');
Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00');
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