Commit 7d7030f4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix 5 system analyst issues: email hint, low stock filter, attendance filter, trainer activities

1. User form: add hint that email is used as login username
2. Dashboard low stock widget: only show products below min_stock_level (was showing all)
3. Dashboard attendance widget: link to filtered view showing only sessions without attendance
4. Trainer form: require at least one activity selected (validation + UI)
5. Cross-reference: show activities on trainer list, show trainers on activity cards
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ad336283
......@@ -2,6 +2,7 @@
namespace App\Livewire\Activities;
use App\Domain\HR\Models\Trainer;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Services\ActivityService;
use Livewire\Attributes\Layout;
......@@ -51,8 +52,18 @@ public function render()
->orderBy('sort_order')
->orderBy('name_ar');
$trainersByActivity = [];
foreach (Trainer::with('employee.person')->where('status', 'active')->get() as $trainer) {
if (!empty($trainer->sports)) {
foreach ($trainer->sports as $actId) {
$trainersByActivity[$actId][] = $trainer->employee->person->name_ar ?? '';
}
}
}
return view('livewire.activities.activity-list', [
'activities' => $query->get(),
'trainersByActivity' => $trainersByActivity,
'categoryLabels' => [
'sport' => 'رياضة',
'art' => 'فنون',
......
......@@ -25,12 +25,19 @@ class AttendanceList extends Component
#[Url]
public string $dateTo = '';
#[Url]
public string $filter = '';
public function mount(): void
{
$this->authorize('attendance.list');
if (!$this->dateFrom) {
$this->dateFrom = now()->subDays(7)->format('Y-m-d');
}
if (!$this->dateTo) {
$this->dateTo = now()->format('Y-m-d');
}
}
public function updatedSearch(): void
{
......@@ -62,6 +69,12 @@ public function render()
->when($this->dateFrom, fn ($q) => $q->where('session_date', '>=', $this->dateFrom))
->when($this->dateTo, fn ($q) => $q->where('session_date', '<=', $this->dateTo))
->when($this->search, fn ($q) => $q->whereHas('group', fn ($gq) => $gq->where('name_ar', 'ilike', "%{$this->search}%")))
->when($this->filter === 'not_taken', function ($q) {
$q->whereIn('status', ['completed', 'in_progress'])
->where(function ($sub) {
$sub->whereDoesntHave('attendanceRecords', fn ($ar) => $ar->whereNotIn('status', ['expected']));
});
})
->orderByDesc('session_date')
->orderByDesc('start_time');
......
......@@ -84,9 +84,8 @@ public function render()
$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'))
$lowStockItems = InventoryLevel::whereHas('product', fn ($q) => $q->where('track_inventory', true)->where('is_active', true)->whereNotNull('min_stock_level')->where('min_stock_level', '>', 0))
->whereRaw('quantity_on_hand < (SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id)')
->count();
$expiringMedicalCerts = Document::where('document_type', DocumentType::MedicalCertificate)
......
......@@ -82,6 +82,8 @@ public function rules(): array
{
return [
'employeeId' => 'required|exists:employees,id',
'sports' => 'required|array|min:1',
'sports.*' => 'integer|exists:activities,id',
'bio' => 'nullable|string|max:2000',
'bioAr' => 'nullable|string|max:2000',
'compensationModel' => 'required|in:' . implode(',', array_column(CompensationModel::cases(), 'value')),
......@@ -100,6 +102,8 @@ public function messages(): array
return [
'employeeId.required' => 'يجب اختيار موظف',
'employeeId.exists' => 'الموظف المختار غير موجود',
'sports.required' => 'يجب اختيار نشاط واحد على الأقل',
'sports.min' => 'يجب اختيار نشاط واحد على الأقل',
'compensationModel.required' => 'نموذج التعويض مطلوب',
'compensationModel.in' => 'نموذج التعويض غير صالح',
'maxDailySessions.integer' => 'الحد الأقصى للحصص يجب أن يكون رقماً صحيحاً',
......
......@@ -6,6 +6,7 @@
use App\Domain\HR\Enums\TrainerStatus;
use App\Domain\HR\Models\Trainer;
use App\Domain\Identity\Services\PermissionService;
use App\Domain\Training\Models\Activity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -68,10 +69,13 @@ public function render()
$query->where('compensation_model', $this->compensationModel);
}
$activities = Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar', 'color']);
return view('livewire.hr.trainer-list', [
'trainers' => $query->paginate(20),
'statuses' => TrainerStatus::cases(),
'compensationModels' => CompensationModel::cases(),
'activities' => $activities,
]);
}
}
......@@ -26,6 +26,9 @@ class ProductList extends Component
#[Url]
public string $activeFilter = '';
#[Url]
public string $stockFilter = '';
public function updatedSearch(): void
{
$this->resetPage();
......@@ -41,6 +44,11 @@ public function updatedActiveFilter(): void
$this->resetPage();
}
public function updatedStockFilter(): void
{
$this->resetPage();
}
public function toggleActive(int $productId): void
{
$product = Product::findOrFail($productId);
......@@ -73,6 +81,12 @@ public function render()
$q->where('is_active', false);
}
})
->when($this->stockFilter === 'low', function ($q) {
$q->where('track_inventory', true)
->whereNotNull('min_stock_level')
->where('min_stock_level', '>', 0)
->whereHas('inventoryLevels', fn ($il) => $il->whereRaw('quantity_on_hand < (SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id)'));
})
->orderByDesc('created_at');
return view('livewire.inventory.product-list', [
......
......@@ -62,6 +62,17 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
<p class="text-sm text-gray-500 line-clamp-2 mb-3">{{ $activity->description_ar }}</p>
@endif
@if(!empty($trainersByActivity[$activity->id]))
<div class="mb-3">
<p class="text-xs font-medium text-gray-500 mb-1">{{ __('المدربين') }}:</p>
<div class="flex flex-wrap gap-1">
@foreach($trainersByActivity[$activity->id] as $trainerName)
<span class="inline-flex px-2 py-0.5 rounded-full text-xs bg-blue-50 text-blue-700">{{ $trainerName }}</span>
@endforeach
</div>
</div>
@endif
<div class="flex items-center justify-between text-sm text-gray-600 mt-4 pt-3 border-t border-gray-100">
<span>{{ $activity->participants_count }} {{ __('مشترك') }}</span>
<div class="flex items-center gap-3">
......
......@@ -5,6 +5,16 @@
<p class="mt-1 text-sm text-gray-500">{{ __('عرض جلسات التدريب ونسب الحضور') }}</p>
</div>
@if($filter === 'not_taken')
<div class="mb-4 flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-100 text-amber-800 rounded-full 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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ __('جلسات لم يُسجل حضورها') }}
</span>
<button wire:click="$set('filter', '')" class="text-xs text-gray-500 hover:text-gray-700 underline">{{ __('عرض الكل') }}</button>
</div>
@endif
<!-- Filters -->
<div class="mb-6 p-3 sm:p-4 bg-white border border-gray-200 rounded-lg">
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
......
......@@ -119,7 +119,7 @@
@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">
<a href="{{ route('inventory.products', ['stockFilter' => 'low']) }}" 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>
......@@ -149,7 +149,7 @@
@endif
@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">
<a href="{{ route('attendance.list', ['filter' => 'not_taken', 'dateFrom' => now()->format('Y-m-d'), 'dateTo' => now()->format('Y-m-d')]) }}" 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>
......
......@@ -48,6 +48,26 @@ class="w-full px-4 py-3 text-start hover:bg-gray-50 border-b border-gray-100 las
@error('employeeId') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
{{-- Activities (Required) --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('الأنشطة') }} <span class="text-red-500">*</span></h2>
<p class="text-sm text-gray-500 mb-3">{{ __('اختر نشاط واحد على الأقل يدربه هذا المدرب') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
@foreach($activities as $activity)
<label class="flex items-center gap-2 p-2.5 rounded-lg border cursor-pointer transition-colors
{{ in_array($activity->id, $sports) ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50' }}">
<input type="checkbox" wire:model="sports" value="{{ $activity->id }}"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full shrink-0" style="background-color: {{ $activity->color }}"></div>
<span class="text-sm font-medium text-gray-700">{{ $activity->name_ar }}</span>
</div>
</label>
@endforeach
</div>
@error('sports') <p class="text-sm text-red-600 mt-2">{{ $message }}</p> @enderror
</div>
{{-- Compensation Model --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('نموذج التعويض') }}</h2>
......
......@@ -46,7 +46,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('المدرب') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('رقم المدرب') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الأنشطة') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('نموذج التعويض') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('التقييم') }}</th>
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الحصص') }}</th>
......@@ -61,7 +61,20 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
<p class="font-medium text-gray-900">{{ $trainer->employee->person->name_ar ?? '-' }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ $trainer->employee->branch?->name_ar ?? '' }}</p>
</td>
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{{ $trainer->trainer_number }}</td>
<td class="px-4 py-3">
@if(!empty($trainer->sports))
<div class="flex flex-wrap gap-1">
@foreach($activities->whereIn('id', $trainer->sports) as $act)
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium" style="background-color: {{ $act->color }}15; color: {{ $act->color }}">
<span class="w-1.5 h-1.5 rounded-full" style="background-color: {{ $act->color }}"></span>
{{ $act->name_ar }}
</span>
@endforeach
</div>
@else
<span class="text-gray-400 text-xs">-</span>
@endif
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{{ $trainer->compensation_model->label() }}
......
......@@ -17,6 +17,17 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-l
</div>
@endif
{{-- Active filter badge --}}
@if($stockFilter === 'low')
<div class="mb-4 flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-100 text-amber-800 rounded-full 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 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>
<button wire:click="$set('stockFilter', '')" class="text-xs text-gray-500 hover:text-gray-700 underline">{{ __('عرض الكل') }}</button>
</div>
@endif
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4">
......
......@@ -21,6 +21,7 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">البريد الإلكتروني *</label>
<input type="email" wire:model="email" dir="ltr" class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 @error('email') border-red-500 @enderror">
<p class="mt-1 text-xs text-gray-500">{{ __('سيُستخدم كاسم مستخدم لتسجيل الدخول') }}</p>
@error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
......
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