Commit e0119a1d authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add Visual Schedule Builder for drag-drop facility scheduling

Full-featured schedule builder with:
- Facility selection, date navigation, weekly view
- Hourly time slot grid showing existing reservations
- Grid/segment view with drag-drop group assignment
- Trainer drag-drop with overlap support
- Collision detection via SpaceCollisionService
- Single-date save and recurring weekly booking (N weeks, multi-day)
- Cancel single reservation or entire recurring series
- RTL Arabic-first UI with Alpine.js interactivity
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 71a6c06f
<?php
namespace App\Livewire\Facilities;
use App\Domain\Facility\Models\Facility;
use App\Domain\Facility\Models\SpaceLayout;
use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Facility\Models\SpaceSegment;
use App\Domain\Facility\Services\SpaceCollisionService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSchedule;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('جدولة المنشآت')]
class VisualScheduleBuilder extends Component
{
#[Url]
public string $facility_id = '';
#[Url]
public string $selectedDay = '';
public string $selectedSlotStart = '';
public string $selectedSlotEnd = '';
// Grid state
public array $gridSegments = [];
public array $assignments = [];
public array $conflicts = [];
// Recurring booking
public bool $showRecurring = false;
public int $recurringWeeks = 8;
public array $recurringDays = [];
// Trainer assignment
public string $trainerSearch = '';
public function mount(): void
{
$this->authorize('schedules.manage');
if (!$this->selectedDay) {
$this->selectedDay = now()->toDateString();
}
}
public function render()
{
$facilities = Facility::active()->with('branch')->orderBy('name_ar')->get();
$branches = Branch::orderBy('name_ar')->get();
$facility = $this->facility_id ? Facility::find($this->facility_id) : null;
$timeSlots = $this->generateTimeSlots($facility);
$existingReservations = $this->getExistingReservations($facility);
$layout = $this->resolveLayout($facility);
$groups = $this->getAvailableGroups();
$trainers = $this->getAvailableTrainers();
return view('livewire.facilities.visual-schedule-builder', [
'facilities' => $facilities,
'branches' => $branches,
'facility' => $facility,
'timeSlots' => $timeSlots,
'existingReservations' => $existingReservations,
'layout' => $layout,
'groups' => $groups,
'trainers' => $trainers,
'dayOfWeek' => $this->selectedDay ? Carbon::parse($this->selectedDay)->dayOfWeek : now()->dayOfWeek,
]);
}
public function selectFacility(string $id): void
{
$this->facility_id = $id;
$this->resetGrid();
}
public function selectDay(string $date): void
{
$this->selectedDay = $date;
$this->resetGrid();
}
public function openSlot(string $start, string $end): void
{
$this->selectedSlotStart = $start;
$this->selectedSlotEnd = $end;
$this->loadGridForSlot();
}
public function closeSlot(): void
{
$this->selectedSlotStart = '';
$this->selectedSlotEnd = '';
$this->gridSegments = [];
$this->assignments = [];
}
public function assignGroupToSegments(int $groupId, array $segmentIds): void
{
if (empty($segmentIds) || !$this->facility_id || !$this->selectedSlotStart) {
return;
}
$facility = Facility::find($this->facility_id);
if (!$facility) return;
$collisionService = app(SpaceCollisionService::class);
$conflicts = $collisionService->check(
$facility->id,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd,
$segmentIds
);
if (!empty($conflicts)) {
$this->conflicts = $conflicts;
return;
}
// Check group isn't already assigned in this slot (anywhere)
foreach ($this->assignments as $assignment) {
if (($assignment['group_id'] ?? null) === $groupId) {
$this->addError('assignment', __('هذه المجموعة معيّنة بالفعل في هذا الوقت'));
return;
}
}
$this->assignments[] = [
'type' => 'group',
'group_id' => $groupId,
'segment_ids' => $segmentIds,
];
$this->conflicts = [];
}
public function assignTrainerToSegments(int $trainerId, array $segmentIds): void
{
if (empty($segmentIds) || !$this->facility_id) {
return;
}
$this->assignments[] = [
'type' => 'trainer',
'trainer_id' => $trainerId,
'segment_ids' => $segmentIds,
];
}
public function removeAssignment(int $index): void
{
unset($this->assignments[$index]);
$this->assignments = array_values($this->assignments);
}
public function saveAssignments(): void
{
if (empty($this->assignments) || !$this->facility_id || !$this->selectedSlotStart) {
return;
}
$facility = Facility::find($this->facility_id);
if (!$facility) return;
$layout = $this->resolveLayout($facility);
$dayOfWeek = Carbon::parse($this->selectedDay)->dayOfWeek;
DB::transaction(function () use ($facility, $layout, $dayOfWeek) {
foreach ($this->assignments as $assignment) {
if ($assignment['type'] === 'group') {
$group = TrainingGroup::find($assignment['group_id']);
if (!$group) continue;
// Create or update training schedule
$schedule = TrainingSchedule::updateOrCreate(
[
'academy_id' => $group->academy_id,
'training_group_id' => $group->id,
'day_of_week' => $dayOfWeek,
'start_time' => $this->selectedSlotStart,
],
[
'end_time' => $this->selectedSlotEnd,
'facility_id' => $facility->id,
'space_reservation_template' => $assignment['segment_ids'],
'effective_from' => $this->selectedDay,
'is_active' => true,
]
);
// Create reservation for the selected date
SpaceReservation::create([
'academy_id' => $group->academy_id,
'facility_id' => $facility->id,
'space_layout_id' => $layout?->id,
'status' => 'confirmed',
'reservation_date' => $this->selectedDay,
'start_time' => $this->selectedSlotStart,
'end_time' => $this->selectedSlotEnd,
'segment_ids' => $assignment['segment_ids'],
'reservable_type' => TrainingSchedule::class,
'reservable_id' => $schedule->id,
'title' => $group->name_ar,
'created_by' => auth()->id(),
]);
} elseif ($assignment['type'] === 'trainer') {
// Trainers are stored in schedule metadata
// Find the schedule for these segments and attach trainer
$relatedSchedule = TrainingSchedule::where('facility_id', $facility->id)
->where('day_of_week', $dayOfWeek)
->where('start_time', $this->selectedSlotStart)
->first();
if ($relatedSchedule) {
if (!$relatedSchedule->trainer_id) {
$relatedSchedule->update(['trainer_id' => $assignment['trainer_id']]);
} else {
$relatedSchedule->update(['assistant_trainer_id' => $assignment['trainer_id']]);
}
}
}
}
});
$this->dispatch('assignment-saved');
session()->flash('success', __('تم حفظ الجدول بنجاح'));
$this->loadGridForSlot();
}
public function saveRecurring(): void
{
if (empty($this->assignments) || !$this->facility_id || !$this->selectedSlotStart) {
return;
}
$facility = Facility::find($this->facility_id);
if (!$facility) return;
$layout = $this->resolveLayout($facility);
$startDate = Carbon::parse($this->selectedDay);
$dayOfWeek = $startDate->dayOfWeek;
$collisionService = app(SpaceCollisionService::class);
$days = !empty($this->recurringDays) ? $this->recurringDays : [$dayOfWeek];
$bookedCount = 0;
$conflictDates = [];
DB::transaction(function () use ($facility, $layout, $startDate, $days, $collisionService, &$bookedCount, &$conflictDates) {
foreach ($this->assignments as $assignment) {
if ($assignment['type'] !== 'group') continue;
$group = TrainingGroup::find($assignment['group_id']);
if (!$group) continue;
foreach ($days as $targetDay) {
// Create the schedule entry
$schedule = TrainingSchedule::updateOrCreate(
[
'academy_id' => $group->academy_id,
'training_group_id' => $group->id,
'day_of_week' => $targetDay,
'start_time' => $this->selectedSlotStart,
],
[
'end_time' => $this->selectedSlotEnd,
'facility_id' => $facility->id,
'space_reservation_template' => $assignment['segment_ids'],
'effective_from' => $startDate->toDateString(),
'effective_until' => $startDate->copy()->addWeeks($this->recurringWeeks)->toDateString(),
'is_active' => true,
]
);
// Book each week
for ($week = 0; $week < $this->recurringWeeks; $week++) {
$date = $startDate->copy()->addWeeks($week);
// Adjust to the correct day
while ($date->dayOfWeek !== $targetDay) {
$date->addDay();
}
$dateStr = $date->toDateString();
// Check conflicts
$conflicts = $collisionService->check(
$facility->id,
$dateStr,
$this->selectedSlotStart,
$this->selectedSlotEnd,
$assignment['segment_ids']
);
if (!empty($conflicts)) {
$conflictDates[] = $dateStr;
continue;
}
SpaceReservation::create([
'academy_id' => $group->academy_id,
'facility_id' => $facility->id,
'space_layout_id' => $layout?->id,
'status' => 'confirmed',
'reservation_date' => $dateStr,
'start_time' => $this->selectedSlotStart,
'end_time' => $this->selectedSlotEnd,
'segment_ids' => $assignment['segment_ids'],
'reservable_type' => TrainingSchedule::class,
'reservable_id' => $schedule->id,
'title' => $group->name_ar,
'is_recurring' => true,
'recurrence_pattern' => 'weekly',
'recurrence_end_date' => $startDate->copy()->addWeeks($this->recurringWeeks)->toDateString(),
'created_by' => auth()->id(),
]);
$bookedCount++;
}
}
}
});
$msg = __('تم حجز') . " {$bookedCount} " . __('جلسة بنجاح');
if (!empty($conflictDates)) {
$msg .= '. ' . __('تم تخطي') . ' ' . count($conflictDates) . ' ' . __('تاريخ بسبب تعارضات');
}
session()->flash('success', $msg);
$this->showRecurring = false;
$this->loadGridForSlot();
}
public function cancelReservation(int $reservationId): void
{
$reservation = SpaceReservation::find($reservationId);
if (!$reservation) return;
$reservation->update([
'status' => 'cancelled',
'cancelled_by' => auth()->id(),
'cancelled_at' => now(),
]);
$this->loadGridForSlot();
session()->flash('success', __('تم إلغاء الحجز'));
}
public function cancelSeries(int $reservationId): void
{
$reservation = SpaceReservation::find($reservationId);
if (!$reservation || !$reservation->reservable_id) return;
SpaceReservation::where('reservable_type', $reservation->reservable_type)
->where('reservable_id', $reservation->reservable_id)
->where('reservation_date', '>=', now()->toDateString())
->where('status', 'confirmed')
->update([
'status' => 'cancelled',
'cancelled_by' => auth()->id(),
'cancelled_at' => now(),
]);
$this->loadGridForSlot();
session()->flash('success', __('تم إلغاء السلسلة بالكامل'));
}
// ─── Private Helpers ─────────────────────────────────────────
private function resetGrid(): void
{
$this->selectedSlotStart = '';
$this->selectedSlotEnd = '';
$this->gridSegments = [];
$this->assignments = [];
$this->conflicts = [];
}
private function loadGridForSlot(): void
{
$facility = Facility::find($this->facility_id);
if (!$facility) return;
$layout = $this->resolveLayout($facility);
if (!$layout) {
$this->gridSegments = [];
return;
}
$segments = $layout->segments()->orderBy('sort_order')->get();
$this->gridSegments = $segments->map(fn ($seg) => [
'id' => $seg->id,
'code' => $seg->code,
'name' => $seg->name_ar ?: $seg->name,
'row' => $seg->row_index,
'col' => $seg->col_index,
'lane' => $seg->lane_number,
'available' => $seg->is_available,
'capacity' => $seg->capacity,
])->toArray();
// Load existing reservations for this slot
$this->assignments = [];
$reservations = SpaceReservation::where('facility_id', $facility->id)
->where('reservation_date', $this->selectedDay)
->where('status', 'confirmed')
->where('start_time', '<', $this->selectedSlotEnd)
->where('end_time', '>', $this->selectedSlotStart)
->with('reservable')
->get();
foreach ($reservations as $res) {
$this->assignments[] = [
'type' => 'existing',
'reservation_id' => $res->id,
'title' => $res->title,
'segment_ids' => $res->segment_ids ?? [],
'is_recurring' => $res->is_recurring,
'reservable_type' => $res->reservable_type,
'reservable_id' => $res->reservable_id,
];
}
}
private function generateTimeSlots(?Facility $facility): array
{
if (!$facility) return [];
$start = $facility->operating_start ?? '06:00';
$end = $facility->operating_end ?? '23:00';
$slots = [];
$current = Carbon::parse($this->selectedDay . ' ' . $start);
$endTime = Carbon::parse($this->selectedDay . ' ' . $end);
while ($current < $endTime) {
$slotEnd = $current->copy()->addHour();
if ($slotEnd > $endTime) $slotEnd = $endTime;
$slots[] = [
'start' => $current->format('H:i'),
'end' => $slotEnd->format('H:i'),
'label' => $current->format('H:i') . ' - ' . $slotEnd->format('H:i'),
];
$current->addHour();
}
return $slots;
}
private function getExistingReservations(?Facility $facility): array
{
if (!$facility || !$this->selectedDay) return [];
return SpaceReservation::where('facility_id', $facility->id)
->where('reservation_date', $this->selectedDay)
->where('status', 'confirmed')
->orderBy('start_time')
->get()
->map(fn ($r) => [
'id' => $r->id,
'title' => $r->title ?? __('حجز'),
'start' => substr($r->start_time, 0, 5),
'end' => substr($r->end_time, 0, 5),
'segment_ids' => $r->segment_ids ?? [],
'is_recurring' => $r->is_recurring,
])
->toArray();
}
private function resolveLayout(?Facility $facility): ?SpaceLayout
{
if (!$facility) return null;
$dayOfWeek = Carbon::parse($this->selectedDay)->dayOfWeek;
// Try specific date first
$layout = SpaceLayout::where('facility_id', $facility->id)
->where('is_active', true)
->where('is_recurring', false)
->where('effective_date', $this->selectedDay)
->first();
if ($layout) return $layout;
// Fall back to recurring for this day
$layout = SpaceLayout::where('facility_id', $facility->id)
->where('is_active', true)
->where('is_recurring', true)
->where('effective_day_of_week', $dayOfWeek)
->first();
if ($layout) return $layout;
// Fall back to any active layout
return SpaceLayout::where('facility_id', $facility->id)
->where('is_active', true)
->orderBy('sort_order')
->first();
}
private function getAvailableGroups(): array
{
return TrainingGroup::whereIn('status', ['active', 'forming', 'full'])
->with(['program', 'headTrainer'])
->orderBy('name_ar')
->get()
->map(fn ($g) => [
'id' => $g->id,
'name' => $g->name_ar,
'code' => $g->code,
'program' => $g->program?->name_ar,
'count' => $g->current_count,
'max' => $g->max_capacity,
'trainer' => $g->headTrainer?->name_ar ?? $g->headTrainer?->name,
'color' => $this->groupColor($g->id),
])
->toArray();
}
private function getAvailableTrainers(): array
{
return User::where('status', 'active')
->whereHas('primaryRole', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer']))
->orderBy('name_ar')
->get()
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name_ar ?? $u->name,
])
->toArray();
}
private function groupColor(int $id): string
{
$colors = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#06B6D4', '#84CC16', '#F97316', '#6366F1',
'#14B8A6', '#D946EF', '#0EA5E9', '#22C55E', '#A855F7',
];
return $colors[$id % count($colors)];
}
}
...@@ -48,6 +48,7 @@ ...@@ -48,6 +48,7 @@
['section' => 'المنشآت', 'items' => [ ['section' => 'المنشآت', 'items' => [
['label' => 'المنشآت', 'route' => 'facilities.list', 'icon' => 'building-office', 'permission' => 'facilities.list'], ['label' => 'المنشآت', 'route' => 'facilities.list', 'icon' => 'building-office', 'permission' => 'facilities.list'],
['label' => 'جدولة المنشآت', 'route' => 'facilities.schedule-builder', 'icon' => 'calendar-days', 'permission' => 'schedules.manage'],
['label' => 'تعيين المساحات', 'route' => 'facilities.space-assignment', 'icon' => 'grid', 'permission' => 'facilities.manage_layouts'], ['label' => 'تعيين المساحات', 'route' => 'facilities.space-assignment', 'icon' => 'grid', 'permission' => 'facilities.manage_layouts'],
]], ]],
......
@php use Illuminate\Support\Carbon; @endphp
<div x-data="scheduleBuilder()" class="flex h-[calc(100vh-4rem)]">
{{-- Sidebar: Groups & Trainers --}}
<aside class="w-72 bg-white border-e border-gray-200 flex flex-col shrink-0 overflow-hidden">
{{-- Facility Selector --}}
<div class="p-4 border-b border-gray-100">
<label class="block text-xs font-medium text-gray-500 mb-1">{{ __('المنشأة') }}</label>
<select wire:model.live="facility_id" class="w-full text-sm border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('اختر المنشأة...') }}</option>
@foreach($facilities as $f)
<option value="{{ $f->id }}">{{ $f->name_ar }} ({{ $f->branch?->name_ar }})</option>
@endforeach
</select>
</div>
{{-- Tabs: Groups / Trainers --}}
<div class="flex border-b border-gray-100">
<button @click="sidebarTab = 'groups'" :class="sidebarTab === 'groups' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'"
class="flex-1 py-2.5 text-xs font-medium border-b-2 transition-colors">{{ __('المجموعات') }}</button>
<button @click="sidebarTab = 'trainers'" :class="sidebarTab === 'trainers' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'"
class="flex-1 py-2.5 text-xs font-medium border-b-2 transition-colors">{{ __('المدربين') }}</button>
</div>
{{-- Search --}}
<div class="p-3">
<input type="text" x-model="sidebarSearch" placeholder="{{ __('بحث...') }}"
class="w-full text-sm border-gray-300 rounded-lg px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- Groups List --}}
<div x-show="sidebarTab === 'groups'" class="flex-1 overflow-y-auto px-3 pb-3 space-y-1.5">
@foreach($groups as $group)
<div draggable="true"
@dragstart="startDrag($event, 'group', {{ json_encode($group) }})"
@dragend="endDrag()"
x-show="!sidebarSearch || '{{ $group['name'] }}'.includes(sidebarSearch) || '{{ $group['program'] }}'.includes(sidebarSearch)"
class="border border-gray-200 rounded-lg p-2.5 cursor-grab active:cursor-grabbing hover:border-blue-300 hover:bg-blue-50/50 transition-colors group/item"
:class="draggingItem?.id === {{ $group['id'] }} && draggingItem?.type === 'group' ? 'ring-2 ring-blue-400 bg-blue-50' : ''">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full shrink-0" style="background-color: {{ $group['color'] }}"></div>
<span class="text-sm font-medium text-gray-800 truncate">{{ $group['name'] }}</span>
</div>
<div class="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{{ $group['program'] }}</span>
<span class="text-gray-300">|</span>
<span dir="ltr">{{ $group['count'] }}/{{ $group['max'] }}</span>
</div>
@if($group['trainer'])
<div class="mt-1 text-xs text-gray-400 truncate">{{ $group['trainer'] }}</div>
@endif
</div>
@endforeach
@if(empty($groups))
<p class="text-center text-xs text-gray-400 py-4">{{ __('لا توجد مجموعات نشطة') }}</p>
@endif
</div>
{{-- Trainers List --}}
<div x-show="sidebarTab === 'trainers'" class="flex-1 overflow-y-auto px-3 pb-3 space-y-1.5">
@foreach($trainers as $trainer)
<div draggable="true"
@dragstart="startDrag($event, 'trainer', {{ json_encode($trainer) }})"
@dragend="endDrag()"
x-show="!sidebarSearch || '{{ $trainer['name'] }}'.includes(sidebarSearch)"
class="border border-gray-200 rounded-lg p-2.5 cursor-grab active:cursor-grabbing hover:border-purple-300 hover:bg-purple-50/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center shrink-0">
<svg class="w-3.5 h-3.5" 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>
<span class="text-sm font-medium text-gray-800">{{ $trainer['name'] }}</span>
</div>
</div>
@endforeach
</div>
</aside>
{{-- Main Content --}}
<main class="flex-1 flex flex-col overflow-hidden bg-gray-50">
{{-- Top bar: Date Navigation --}}
<div class="bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2">
<button wire:click="selectDay('{{ Carbon::parse($selectedDay)->subDay()->toDateString() }}')"
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
<div class="flex items-center gap-3">
<input type="date" wire:model.live="selectedDay" class="border-gray-300 rounded-lg text-sm focus:ring-blue-500 focus:border-blue-500" dir="ltr">
<span class="text-sm font-medium text-gray-700">
{{ Carbon::parse($selectedDay)->translatedFormat('l j F Y') }}
</span>
</div>
<button wire:click="selectDay('{{ Carbon::parse($selectedDay)->addDay()->toDateString() }}')"
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
</div>
{{-- Week quick-nav --}}
<div class="flex items-center gap-1">
@for($d = 0; $d < 7; $d++)
@php $date = Carbon::parse($selectedDay)->startOfWeek()->addDays($d); @endphp
<button wire:click="selectDay('{{ $date->toDateString() }}')"
class="w-9 h-9 rounded-lg text-xs font-medium {{ $date->toDateString() === $selectedDay ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-600' }}">
{{ $date->translatedFormat('D') }}
</button>
@endfor
</div>
<button wire:click="selectDay('{{ now()->toDateString() }}')" class="px-3 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-50 rounded-lg">
{{ __('اليوم') }}
</button>
</div>
{{-- Flash messages --}}
@if(session('success'))
<div class="mx-4 mt-3 p-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ session('success') }}
</div>
@endif
@if(!empty($conflicts))
<div class="mx-4 mt-3 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
<strong>{{ __('تعارض!') }}</strong>
@foreach($conflicts as $c)
{{ $c['title'] ?? __('حجز') }} ({{ $c['time'] }})
@endforeach
</div>
@endif
{{-- Content Area --}}
@if(!$facility)
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-gray-500 font-medium">{{ __('اختر منشأة من القائمة الجانبية') }}</p>
<p class="text-gray-400 text-sm mt-1">{{ __('لبدء جدولة المجموعات والمدربين') }}</p>
</div>
</div>
@elseif(!$selectedSlotStart)
{{-- Time Slots Grid --}}
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach($timeSlots as $slot)
@php
$slotReservations = collect($existingReservations)->filter(fn($r) => $r['start'] < $slot['end'] && $r['end'] > $slot['start']);
$isBusy = $slotReservations->isNotEmpty();
@endphp
<button wire:click="openSlot('{{ $slot['start'] }}', '{{ $slot['end'] }}')"
class="relative border rounded-xl p-4 text-start transition-all hover:shadow-md {{ $isBusy ? 'border-blue-200 bg-blue-50/50 hover:border-blue-400' : 'border-gray-200 bg-white hover:border-blue-300' }}">
<div class="flex items-center justify-between mb-2">
<span class="text-lg font-bold text-gray-800" dir="ltr">{{ $slot['label'] }}</span>
@if($isBusy)
<span class="w-2.5 h-2.5 bg-blue-500 rounded-full animate-pulse"></span>
@endif
</div>
@if($isBusy)
<div class="space-y-1">
@foreach($slotReservations->take(3) as $sr)
<p class="text-xs text-blue-700 truncate">{{ $sr['title'] }}</p>
@endforeach
@if($slotReservations->count() > 3)
<p class="text-xs text-blue-400">+{{ $slotReservations->count() - 3 }} {{ __('أخرى') }}</p>
@endif
</div>
@else
<p class="text-xs text-gray-400">{{ __('متاح') }}</p>
@endif
</button>
@endforeach
</div>
@if(empty($timeSlots))
<div class="text-center py-12">
<p class="text-gray-500">{{ __('لم يتم تحديد ساعات العمل لهذه المنشأة') }}</p>
<a href="{{ route('facilities.list') }}" wire:navigate class="text-sm text-blue-600 hover:text-blue-700 mt-2 inline-block">{{ __('تعديل المنشأة') }}</a>
</div>
@endif
</div>
@else
{{-- Grid View: Segments + Assignments --}}
<div class="flex-1 flex flex-col overflow-hidden">
{{-- Slot Header --}}
<div class="bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<button wire:click="closeSlot" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<div>
<span class="text-sm font-bold text-gray-800" dir="ltr">{{ $selectedSlotStart }} — {{ $selectedSlotEnd }}</span>
<span class="text-xs text-gray-500 ms-2">{{ $facility->name_ar }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="$wire.set('showRecurring', true)" class="px-3 py-1.5 text-xs font-medium text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-50">
<svg class="w-4 h-4 inline me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
{{ __('تكرار أسبوعي') }}
</button>
<button wire:click="saveAssignments" class="px-4 py-1.5 text-xs font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
wire:loading.attr="disabled" wire:target="saveAssignments">
<span wire:loading.remove wire:target="saveAssignments">{{ __('حفظ') }}</span>
<span wire:loading wire:target="saveAssignments">{{ __('جارٍ الحفظ...') }}</span>
</button>
</div>
</div>
{{-- Grid Area --}}
<div class="flex-1 overflow-auto p-4">
@if(!empty($gridSegments))
@php
$maxRow = collect($gridSegments)->max('row') ?? 0;
$maxCol = collect($gridSegments)->max('col') ?? 0;
$isGrid = $maxRow > 0 || $maxCol > 0;
$cols = $isGrid ? $maxCol + 1 : count($gridSegments);
$rows = $isGrid ? $maxRow + 1 : 1;
@endphp
<div class="{{ $isGrid ? 'grid gap-3' : 'flex flex-wrap gap-3' }}"
style="{{ $isGrid ? 'grid-template-columns: repeat(' . $cols . ', minmax(0, 1fr)); grid-template-rows: repeat(' . $rows . ', minmax(120px, 1fr))' : '' }}">
@foreach($gridSegments as $seg)
@php
$segAssignment = collect($assignments)->first(fn($a) => in_array($seg['id'], $a['segment_ids'] ?? []));
$isOccupied = $segAssignment !== null;
$isExisting = $isOccupied && ($segAssignment['type'] ?? '') === 'existing';
@endphp
<div
@dragover.prevent="highlightSegment({{ $seg['id'] }})"
@dragleave="unhighlightSegment({{ $seg['id'] }})"
@drop.prevent="dropOnSegment({{ $seg['id'] }})"
@click="toggleSegmentSelection({{ $seg['id'] }})"
:class="{
'ring-2 ring-blue-400 bg-blue-50': selectedSegments.includes({{ $seg['id'] }}),
'ring-2 ring-green-400 bg-green-50': highlightedSegment === {{ $seg['id'] }},
}"
class="relative border-2 rounded-xl p-3 min-h-[120px] flex flex-col transition-all cursor-pointer
{{ !$seg['available'] ? 'border-gray-300 bg-gray-100 cursor-not-allowed opacity-60' : '' }}
{{ $isExisting ? 'border-blue-300 bg-blue-50' : '' }}
{{ !$isOccupied && $seg['available'] ? 'border-dashed border-gray-300 hover:border-blue-300 hover:bg-blue-50/30' : '' }}"
@if($isGrid) style="grid-row: {{ ($seg['row'] ?? 0) + 1 }}; grid-column: {{ ($seg['col'] ?? 0) + 1 }}" @endif
>
{{-- Cell header --}}
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-mono text-gray-400">{{ $seg['code'] }}</span>
@if($seg['capacity'])
<span class="text-[10px] text-gray-400" dir="ltr">{{ $seg['capacity'] }}p</span>
@endif
</div>
{{-- Content --}}
@if($isExisting)
<div class="flex-1 flex flex-col justify-center">
<p class="text-sm font-semibold text-blue-800 truncate">{{ $segAssignment['title'] ?? '' }}</p>
@if($segAssignment['is_recurring'] ?? false)
<span class="inline-flex items-center gap-1 mt-1 text-[10px] text-purple-600">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
{{ __('متكرر') }}
</span>
@endif
</div>
{{-- Cancel buttons --}}
<div class="flex gap-1 mt-2">
<button wire:click="cancelReservation({{ $segAssignment['reservation_id'] }})"
wire:confirm="{{ __('إلغاء هذا الحجز؟') }}"
class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إلغاء') }}</button>
@if($segAssignment['is_recurring'] ?? false)
<button wire:click="cancelSeries({{ $segAssignment['reservation_id'] }})"
wire:confirm="{{ __('إلغاء كل حجوزات هذه السلسلة المستقبلية؟') }}"
class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إلغاء السلسلة') }}</button>
@endif
</div>
@elseif(!$seg['available'])
<div class="flex-1 flex items-center justify-center">
<span class="text-xs text-gray-400">{{ __('غير متاح') }}</span>
</div>
@else
<div class="flex-1 flex items-center justify-center">
<span class="text-xs text-gray-400" x-show="!selectedSegments.includes({{ $seg['id'] }})">{{ __('اسحب مجموعة هنا') }}</span>
<span class="text-xs text-blue-600 font-medium" x-show="selectedSegments.includes({{ $seg['id'] }})">{{ __('محدد') }}</span>
</div>
@endif
</div>
@endforeach
</div>
{{-- Legend --}}
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 border-2 border-dashed border-gray-300 rounded"></span> {{ __('متاح') }}</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></span> {{ __('محجوز') }}</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 bg-gray-100 border border-gray-300 rounded"></span> {{ __('غير متاح') }}</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 border-2 border-blue-400 rounded ring-2 ring-blue-400/30"></span> {{ __('محدد') }}</span>
</div>
{{-- Quick assign selected --}}
<div x-show="selectedSegments.length > 0" x-transition class="mt-4 bg-blue-50 border border-blue-200 rounded-xl p-4">
<p class="text-sm font-medium text-blue-800 mb-2">
{{ __('تم تحديد') }} <span x-text="selectedSegments.length" class="font-bold"></span> {{ __('خلية') }}
</p>
<p class="text-xs text-blue-600 mb-3">{{ __('اسحب مجموعة أو مدرب على الخلايا المحددة، أو انقر مجموعة من القائمة') }}</p>
<button @click="selectedSegments = []" class="text-xs text-gray-500 hover:text-gray-700">{{ __('إلغاء التحديد') }}</button>
</div>
@else
<div class="text-center py-12">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/></svg>
<p class="text-gray-500 font-medium">{{ __('لا يوجد تخطيط لهذه المنشأة') }}</p>
<p class="text-gray-400 text-sm mt-1">{{ __('أنشئ تخطيطاً أولاً من صفحة إدارة المنشآت') }}</p>
@if($facility)
<a href="{{ route('facilities.layouts', $facility) }}" wire:navigate class="inline-block mt-3 text-sm text-blue-600 hover:text-blue-700 font-medium">
{{ __('إنشاء تخطيط') }}
</a>
@endif
</div>
@endif
</div>
</div>
@endif
</main>
{{-- Recurring Booking Modal --}}
@if($showRecurring)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="$wire.set('showRecurring', false)">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">{{ __('تكرار الحجز أسبوعياً') }}</h3>
<div class="space-y-4">
{{-- Duration --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('عدد الأسابيع') }}</label>
<div class="flex items-center gap-3">
<input type="range" wire:model="recurringWeeks" min="1" max="52" class="flex-1">
<span class="text-sm font-bold text-blue-600 w-12 text-center" dir="ltr">{{ $recurringWeeks }}</span>
</div>
<p class="text-xs text-gray-400 mt-1">
{{ __('من') }} {{ Carbon::parse($selectedDay)->translatedFormat('j M') }}
{{ __('إلى') }} {{ Carbon::parse($selectedDay)->addWeeks($recurringWeeks)->translatedFormat('j M Y') }}
</p>
</div>
{{-- Days of week --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('أيام التكرار') }}</label>
<div class="flex flex-wrap gap-2">
@php $dayNames = ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت']; @endphp
@for($d = 0; $d < 7; $d++)
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 border rounded-lg cursor-pointer text-xs
{{ $d === $dayOfWeek ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 hover:border-blue-200' }}">
<input type="checkbox" wire:model="recurringDays" value="{{ $d }}" class="w-3.5 h-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
{{ $dayNames[$d] }}
</label>
@endfor
</div>
<p class="text-xs text-gray-400 mt-1">{{ __('اترك فارغاً للتكرار في نفس اليوم فقط') }}</p>
</div>
{{-- Summary --}}
<div class="bg-gray-50 rounded-lg p-3 text-sm">
<p class="text-gray-600">
{{ __('سيتم حجز') }}
<span class="font-bold">{{ count($assignments) }}</span> {{ __('مجموعة') }}
{{ __('لمدة') }}
<span class="font-bold">{{ $recurringWeeks }}</span> {{ __('أسبوع') }}
</p>
<p class="text-xs text-gray-400 mt-1">{{ __('سيتم تخطي التواريخ التي بها تعارضات تلقائياً') }}</p>
</div>
</div>
<div class="flex items-center justify-end gap-3 mt-6">
<button @click="$wire.set('showRecurring', false)" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">{{ __('إلغاء') }}</button>
<button wire:click="saveRecurring" class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
wire:loading.attr="disabled" wire:target="saveRecurring">
<span wire:loading.remove wire:target="saveRecurring">{{ __('حجز متكرر') }}</span>
<span wire:loading wire:target="saveRecurring">{{ __('جارٍ الحجز...') }}</span>
</button>
</div>
</div>
</div>
@endif
</div>
@script
<script>
Alpine.data('scheduleBuilder', () => ({
sidebarTab: 'groups',
sidebarSearch: '',
draggingItem: null,
selectedSegments: [],
highlightedSegment: null,
startDrag(event, type, item) {
this.draggingItem = { type, ...item };
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', JSON.stringify({ type, id: item.id }));
},
endDrag() {
this.draggingItem = null;
this.highlightedSegment = null;
},
highlightSegment(segId) {
this.highlightedSegment = segId;
},
unhighlightSegment(segId) {
if (this.highlightedSegment === segId) {
this.highlightedSegment = null;
}
},
dropOnSegment(segId) {
this.highlightedSegment = null;
if (!this.draggingItem) return;
const targetSegments = this.selectedSegments.length > 0 && this.selectedSegments.includes(segId)
? [...this.selectedSegments]
: [segId];
if (this.draggingItem.type === 'group') {
this.$wire.assignGroupToSegments(this.draggingItem.id, targetSegments);
} else if (this.draggingItem.type === 'trainer') {
this.$wire.assignTrainerToSegments(this.draggingItem.id, targetSegments);
}
this.selectedSegments = [];
this.draggingItem = null;
},
toggleSegmentSelection(segId) {
const idx = this.selectedSegments.indexOf(segId);
if (idx > -1) {
this.selectedSegments.splice(idx, 1);
} else {
this.selectedSegments.push(segId);
}
},
}));
</script>
@endscript
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
use App\Livewire\Evaluations\EvaluationShow; use App\Livewire\Evaluations\EvaluationShow;
use App\Livewire\Facilities\FacilityForm; use App\Livewire\Facilities\FacilityForm;
use App\Livewire\Facilities\FacilityList; use App\Livewire\Facilities\FacilityList;
use App\Livewire\Facilities\VisualScheduleBuilder;
use App\Livewire\Financial\FinancialOverview; use App\Livewire\Financial\FinancialOverview;
use App\Livewire\Auth\Login; use App\Livewire\Auth\Login;
use App\Livewire\CashSessions\CashSessionList; use App\Livewire\CashSessions\CashSessionList;
...@@ -202,6 +203,8 @@ ...@@ -202,6 +203,8 @@
->middleware('permission:facilities.manage_layouts'); ->middleware('permission:facilities.manage_layouts');
Route::get('/facilities/space-assignment', \App\Livewire\Facilities\SpaceAssignmentWizard::class)->name('facilities.space-assignment') Route::get('/facilities/space-assignment', \App\Livewire\Facilities\SpaceAssignmentWizard::class)->name('facilities.space-assignment')
->middleware('permission:facilities.manage_layouts'); ->middleware('permission:facilities.manage_layouts');
Route::get('/facilities/schedule-builder', VisualScheduleBuilder::class)->name('facilities.schedule-builder')
->middleware('permission:schedules.manage');
// Attendance // Attendance
Route::get('/attendance', AttendanceList::class)->name('attendance.list') Route::get('/attendance', AttendanceList::class)->name('attendance.list')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment