Commit 40d21535 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add visual Space Assignment system for groups to facility segments

Features:
- SpaceLayoutManager: full CRUD for facility layouts (grid/lanes/zones/custom)
  with visual segment preview and toggle availability
- SpaceAssignmentWizard: 5-step wizard for assigning groups to facility segments
  Step 1: Search and select training group
  Step 2: Choose which schedule slot to assign
  Step 3: Pick the facility (if not already linked)
  Step 4: Visual grid/lane/zone selector with real-time collision detection
  Step 5: Success confirmation
- Visual grid renders as clickable cells showing available/selected/occupied/disabled states
- Real-time collision checking against existing confirmed reservations
- Saves space_reservation_template on TrainingSchedule for auto-reservation on session creation
- Fix: ReservationService.autoReserveForSession() now uses correct field names
  (space_reservation_template instead of segments, segment_ids instead of segments key)
- Added "التخطيط" action link in facility list table
- Added "تعيين المساحات" to sidebar navigation
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4c209476
......@@ -137,7 +137,8 @@ public function cancelForSession(Model $session, User $actor): void
*/
public function autoReserveForSession(\App\Domain\Training\Models\TrainingSession $session, \App\Domain\Training\Models\TrainingSchedule $schedule): void
{
if (!$schedule->facility_id || !$schedule->segments) {
$segmentIds = $schedule->space_reservation_template ?? [];
if (!$schedule->facility_id || empty($segmentIds)) {
return;
}
......@@ -148,7 +149,7 @@ public function autoReserveForSession(\App\Domain\Training\Models\TrainingSessio
'reservation_date' => $session->session_date->toDateString(),
'start_time' => $session->start_time,
'end_time' => $session->end_time,
'segments' => $schedule->segments,
'segment_ids' => $segmentIds,
'status' => 'confirmed',
'notes' => "حجز تلقائي للحصة #{$session->session_number}",
], \App\Models\User::find($session->trainer_id) ?? \App\Models\User::first(), $session);
......
This diff is collapsed.
<?php
namespace App\Livewire\Facilities;
use App\Domain\Facility\Models\Facility;
use App\Domain\Facility\Models\SpaceLayout;
use App\Domain\Facility\Services\SpaceLayoutService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إدارة تخطيط الملاعب')]
class SpaceLayoutManager extends Component
{
public Facility $facility;
public array $layouts = [];
// Form state
public bool $showForm = false;
public ?int $editingLayoutId = null;
public string $name = '';
public string $name_ar = '';
public string $layout_type = 'grid';
public bool $is_recurring = true;
public ?int $effective_day_of_week = 6;
public ?string $effective_date = null;
public string $start_time = '08:00';
public string $end_time = '22:00';
// Grid config
public int $grid_rows = 2;
public int $grid_columns = 3;
// Lanes config
public int $lane_count = 4;
// Zones config
public array $zone_definitions = [];
public function mount(Facility $facility): void
{
$this->authorize('facilities.manage_layouts');
$this->facility = $facility;
$this->loadLayouts();
}
public function loadLayouts(): void
{
$this->layouts = $this->facility->layouts()
->with('segments')
->orderBy('is_recurring', 'desc')
->orderBy('effective_day_of_week')
->orderBy('start_time')
->get()
->toArray();
}
public function openCreateForm(): void
{
$this->resetForm();
$this->showForm = true;
}
public function editLayout(int $id): void
{
$layout = SpaceLayout::with('segments')->findOrFail($id);
$this->editingLayoutId = $id;
$this->name = $layout->name;
$this->name_ar = $layout->name_ar;
$this->layout_type = $layout->layout_type->value;
$this->is_recurring = $layout->is_recurring;
$this->effective_day_of_week = $layout->effective_day_of_week;
$this->effective_date = $layout->effective_date?->format('Y-m-d');
$this->start_time = $layout->start_time;
$this->end_time = $layout->end_time;
$config = $layout->layout_config;
match ($this->layout_type) {
'grid' => (function () use ($config) {
$this->grid_rows = $config['rows'] ?? 2;
$this->grid_columns = $config['columns'] ?? 3;
})(),
'lanes' => $this->lane_count = $config['lane_count'] ?? 4,
'zones', 'custom' => $this->zone_definitions = $config['definitions'] ?? [],
default => null,
};
$this->showForm = true;
}
public function save(SpaceLayoutService $service): void
{
$this->validate([
'name' => 'required|string|max:255',
'name_ar' => 'required|string|max:255',
'layout_type' => 'required|in:grid,lanes,zones,custom',
'start_time' => 'required|date_format:H:i',
'end_time' => 'required|date_format:H:i|after:start_time',
]);
$layoutConfig = match ($this->layout_type) {
'grid' => ['rows' => $this->grid_rows, 'columns' => $this->grid_columns],
'lanes' => ['lane_count' => $this->lane_count],
'zones', 'custom' => ['definitions' => $this->zone_definitions],
default => [],
};
$data = [
'academy_id' => $this->facility->academy_id,
'facility_id' => $this->facility->id,
'name' => $this->name,
'name_ar' => $this->name_ar,
'layout_type' => $this->layout_type,
'layout_config' => $layoutConfig,
'is_recurring' => $this->is_recurring,
'effective_day_of_week' => $this->is_recurring ? $this->effective_day_of_week : null,
'effective_date' => !$this->is_recurring ? $this->effective_date : null,
'start_time' => $this->start_time,
'end_time' => $this->end_time,
];
try {
if ($this->editingLayoutId) {
$layout = SpaceLayout::findOrFail($this->editingLayoutId);
$service->update($layout, $data);
session()->flash('success', __('تم تحديث التخطيط بنجاح'));
} else {
$service->create($data, auth()->user());
session()->flash('success', __('تم إنشاء التخطيط بنجاح'));
}
$this->showForm = false;
$this->resetForm();
$this->loadLayouts();
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function deleteLayout(int $id): void
{
try {
$layout = SpaceLayout::findOrFail($id);
app(SpaceLayoutService::class)->delete($layout);
session()->flash('success', __('تم حذف التخطيط'));
$this->loadLayouts();
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function toggleSegmentAvailability(int $segmentId): void
{
$segment = \App\Domain\Facility\Models\SpaceSegment::findOrFail($segmentId);
$segment->update(['is_available' => !$segment->is_available]);
$this->loadLayouts();
}
public function addZone(): void
{
$index = count($this->zone_definitions) + 1;
$this->zone_definitions[] = [
'code' => 'Z' . $index,
'name' => 'Zone ' . $index,
'name_ar' => 'منطقة ' . $index,
'capacity' => null,
];
}
public function removeZone(int $index): void
{
unset($this->zone_definitions[$index]);
$this->zone_definitions = array_values($this->zone_definitions);
}
private function resetForm(): void
{
$this->editingLayoutId = null;
$this->name = '';
$this->name_ar = '';
$this->layout_type = 'grid';
$this->is_recurring = true;
$this->effective_day_of_week = 6;
$this->effective_date = null;
$this->start_time = '08:00';
$this->end_time = '22:00';
$this->grid_rows = 2;
$this->grid_columns = 3;
$this->lane_count = 4;
$this->zone_definitions = [];
}
public function render()
{
return view('livewire.facilities.space-layout-manager');
}
}
......@@ -47,6 +47,7 @@
['section' => 'المنشآت', 'items' => [
['label' => 'المنشآت', 'route' => 'facilities.list', 'icon' => 'building-office', 'permission' => 'facilities.list'],
['label' => 'تعيين المساحات', 'route' => 'facilities.space-assignment', 'icon' => 'grid', 'permission' => 'facilities.manage_layouts'],
]],
['section' => 'الإشعارات', 'items' => [
......@@ -102,6 +103,7 @@
'cog-6-tooth' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
'reception' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>',
'swatch' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"/>',
'grid' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>',
];
$permissionService = app(\App\Domain\Identity\Services\PermissionService::class);
......
......@@ -104,6 +104,10 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
</td>
<td class="px-4 py-3 text-end">
<div class="flex items-center justify-end gap-2">
@permission('facilities.manage_layouts')
<a href="{{ route('facilities.layouts', $facility) }}" wire:navigate
class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">{{ __('التخطيط') }}</a>
@endpermission
@permission('facilities.update')
<a href="{{ route('facilities.edit', $facility) }}" wire:navigate
class="text-blue-600 hover:text-blue-800 text-sm font-medium">{{ __('تعديل') }}</a>
......
......@@ -193,6 +193,10 @@
->middleware('permission:facilities.create');
Route::get('/facilities/{facility}/edit', FacilityForm::class)->name('facilities.edit')
->middleware('permission:facilities.update');
Route::get('/facilities/{facility}/layouts', \App\Livewire\Facilities\SpaceLayoutManager::class)->name('facilities.layouts')
->middleware('permission:facilities.manage_layouts');
Route::get('/facilities/space-assignment', \App\Livewire\Facilities\SpaceAssignmentWizard::class)->name('facilities.space-assignment')
->middleware('permission:facilities.manage_layouts');
// Attendance
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