Commit 4794a02c authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add comprehensive conflict detection + flexible time to schedule builder

ScheduleConflictService checks ALL sources of trainer/group conflicts:
- TrainingSchedule (recurring weekly assignments)
- TrainingSession (specific date overrides)
- TrainingGroup.head_trainer_id (implied trainer commitment)
- Assignment model (formal polymorphic assignments)

Schedule Builder improvements:
- Flexible time picker (any minute interval, not just hourly)
- Trainer availability indicators (green dot = free, orange = busy)
- Group availability indicators (red warning if scheduled elsewhere)
- Implied trainer warnings (group's head trainer conflict = soft warning)
- New vs existing assignments visually distinct (green = unsaved)
- Trainer name shown on existing reservations
- Row/column/all selection helpers
- Recurring booking checks group + trainer + space conflicts per date
- Cancel series now deactivates the underlying TrainingSchedule
- Final validation pass before commit (double-check for race conditions)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e0119a1d
<?php
namespace App\Domain\Scheduling\Services;
use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSchedule;
use App\Domain\Training\Models\TrainingSession;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class ScheduleConflictService
{
/**
* Check ALL conflicts for a trainer at a given date+time.
* Returns array of conflict objects describing what blocks them.
*/
public function checkTrainerConflicts(
int $trainerId,
string $date,
string $startTime,
string $endTime,
?int $excludeScheduleId = null,
?int $excludeSessionId = null
): array {
$dayOfWeek = Carbon::parse($date)->dayOfWeek;
$conflicts = [];
// 1. Check TrainingSchedule: trainer is assigned (primary or assistant) at overlapping time
$scheduleConflicts = TrainingSchedule::where('is_active', true)
->where('day_of_week', $dayOfWeek)
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->where(function ($q) use ($trainerId) {
$q->where('trainer_id', $trainerId)
->orWhere('assistant_trainer_id', $trainerId);
})
->when($excludeScheduleId, fn ($q) => $q->where('id', '!=', $excludeScheduleId))
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->with('group')
->get();
foreach ($scheduleConflicts as $schedule) {
$role = $schedule->trainer_id === $trainerId ? 'مدرب أساسي' : 'مدرب مساعد';
$conflicts[] = [
'type' => 'schedule',
'source' => 'training_schedule',
'id' => $schedule->id,
'title' => $schedule->group?->name_ar ?? __('مجموعة غير معروفة'),
'role' => $role,
'time' => substr($schedule->start_time, 0, 5) . ' - ' . substr($schedule->end_time, 0, 5),
'message' => __('المدرب مسؤول عن') . ' "' . ($schedule->group?->name_ar ?? '') . '" ' . __('كـ') . $role,
];
}
// 2. Check TrainingSession: trainer assigned to a specific session on this date
$sessionConflicts = TrainingSession::where('session_date', $date)
->whereIn('status', ['scheduled', 'in_progress'])
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->where(function ($q) use ($trainerId) {
$q->where('trainer_id', $trainerId)
->orWhere('assistant_trainer_id', $trainerId);
})
->when($excludeSessionId, fn ($q) => $q->where('id', '!=', $excludeSessionId))
->with('group')
->get();
foreach ($sessionConflicts as $session) {
$role = $session->trainer_id === $trainerId ? 'مدرب أساسي' : 'مدرب مساعد';
$conflicts[] = [
'type' => 'session',
'source' => 'training_session',
'id' => $session->id,
'title' => $session->group?->name_ar ?? __('جلسة'),
'role' => $role,
'time' => substr($session->start_time, 0, 5) . ' - ' . substr($session->end_time, 0, 5),
'message' => __('المدرب مكلّف بجلسة') . ' "' . ($session->group?->name_ar ?? '') . '" ' . __('في هذا الوقت'),
];
}
// 3. Check TrainingGroup.head_trainer_id — if trainer is head of a group
// that has schedules at this time
$headGroupConflicts = TrainingSchedule::where('is_active', true)
->where('day_of_week', $dayOfWeek)
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->when($excludeScheduleId, fn ($q) => $q->where('id', '!=', $excludeScheduleId))
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->whereNull('trainer_id') // Only if schedule doesn't have its own trainer
->whereHas('group', fn ($q) => $q->where('head_trainer_id', $trainerId))
->with('group')
->get();
foreach ($headGroupConflicts as $schedule) {
$conflicts[] = [
'type' => 'head_trainer',
'source' => 'group_head_trainer',
'id' => $schedule->id,
'title' => $schedule->group?->name_ar ?? '',
'role' => 'مدرب رئيسي للمجموعة',
'time' => substr($schedule->start_time, 0, 5) . ' - ' . substr($schedule->end_time, 0, 5),
'message' => __('المدرب هو المدرب الرئيسي لمجموعة') . ' "' . ($schedule->group?->name_ar ?? '') . '" ' . __('المجدولة في نفس الوقت'),
];
}
// 4. Check formal Assignment model
$assignmentConflicts = Assignment::where('user_id', $trainerId)
->where('status', 'active')
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('start_date')->orWhere('start_date', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('end_date')->orWhere('end_date', '>=', $date);
});
})
->where(function ($q) use ($startTime, $endTime) {
$q->where(function ($q2) use ($startTime, $endTime) {
$q2->whereNotNull('start_time')
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime);
})->orWhereNull('start_time'); // Full-day assignments
})
->where(function ($q) use ($dayOfWeek) {
$q->whereNull('schedule_days')
->orWhereJsonContains('schedule_days', $dayOfWeek);
})
->get();
foreach ($assignmentConflicts as $assignment) {
$assignableName = '';
if ($assignment->assignable) {
$assignableName = $assignment->assignable->name_ar ?? $assignment->assignable->name ?? '';
}
$conflicts[] = [
'type' => 'assignment',
'source' => 'formal_assignment',
'id' => $assignment->id,
'title' => $assignableName ?: $assignment->role_label,
'role' => $assignment->role_label ?? $assignment->scope,
'time' => $assignment->start_time
? (substr($assignment->start_time, 0, 5) . ' - ' . substr($assignment->end_time, 0, 5))
: __('طوال اليوم'),
'message' => __('تعيين رسمي:') . ' ' . $assignment->role_label . ' — ' . $assignableName,
];
}
return $conflicts;
}
/**
* Check ALL conflicts for a training group at a given date+time+facility.
* A group can't be in two places at once.
*/
public function checkGroupConflicts(
int $groupId,
string $date,
string $startTime,
string $endTime,
?int $excludeScheduleId = null
): array {
$dayOfWeek = Carbon::parse($date)->dayOfWeek;
$conflicts = [];
// 1. Check if group already has a schedule at this time (different facility or segments)
$scheduleConflicts = TrainingSchedule::where('training_group_id', $groupId)
->where('is_active', true)
->where('day_of_week', $dayOfWeek)
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->when($excludeScheduleId, fn ($q) => $q->where('id', '!=', $excludeScheduleId))
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->with('facility')
->get();
foreach ($scheduleConflicts as $schedule) {
$conflicts[] = [
'type' => 'group_schedule',
'source' => 'training_schedule',
'id' => $schedule->id,
'facility' => $schedule->facility?->name_ar ?? '',
'time' => substr($schedule->start_time, 0, 5) . ' - ' . substr($schedule->end_time, 0, 5),
'message' => __('المجموعة مجدولة بالفعل في') . ' "' . ($schedule->facility?->name_ar ?? '') . '" ' . __('في نفس الوقت'),
];
}
// 2. Check if group has a session on this specific date at this time
$sessionConflicts = TrainingSession::where('training_group_id', $groupId)
->where('session_date', $date)
->whereIn('status', ['scheduled', 'in_progress'])
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->with('facility')
->get();
foreach ($sessionConflicts as $session) {
$conflicts[] = [
'type' => 'group_session',
'source' => 'training_session',
'id' => $session->id,
'facility' => $session->facility?->name_ar ?? '',
'time' => substr($session->start_time, 0, 5) . ' - ' . substr($session->end_time, 0, 5),
'message' => __('المجموعة لديها جلسة محددة في') . ' "' . ($session->facility?->name_ar ?? '') . '"',
];
}
// 3. Check confirmed space reservations for this group's schedule
$reservationConflicts = SpaceReservation::where('reservation_date', $date)
->where('status', 'confirmed')
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->where('reservable_type', TrainingSchedule::class)
->whereIn('reservable_id', function ($q) use ($groupId) {
$q->select('id')
->from('training_schedules')
->where('training_group_id', $groupId);
})
->with('facility')
->get();
foreach ($reservationConflicts as $reservation) {
// Only flag if it's a DIFFERENT facility than what we're assigning to
$conflicts[] = [
'type' => 'group_reservation',
'source' => 'space_reservation',
'id' => $reservation->id,
'facility' => $reservation->facility?->name_ar ?? '',
'time' => substr($reservation->start_time, 0, 5) . ' - ' . substr($reservation->end_time, 0, 5),
'message' => __('المجموعة لديها حجز مؤكد في') . ' "' . ($reservation->facility?->name_ar ?? '') . '"',
];
}
return $conflicts;
}
/**
* Get the default trainer(s) for a group at a given time.
* Returns trainers who will be automatically committed when the group is scheduled.
*/
public function getImpliedTrainers(int $groupId, string $date, string $startTime, string $endTime): array
{
$dayOfWeek = Carbon::parse($date)->dayOfWeek;
$group = TrainingGroup::with('headTrainer')->find($groupId);
if (!$group) return [];
$trainers = [];
// Check if there's already a schedule with explicit trainer
$schedule = TrainingSchedule::where('training_group_id', $groupId)
->where('is_active', true)
->where('day_of_week', $dayOfWeek)
->where('start_time', '<', $endTime)
->where('end_time', '>', $startTime)
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->first();
if ($schedule) {
if ($schedule->trainer_id) {
$trainers[] = [
'id' => $schedule->trainer_id,
'source' => 'schedule',
'role' => 'مدرب أساسي',
];
}
if ($schedule->assistant_trainer_id) {
$trainers[] = [
'id' => $schedule->assistant_trainer_id,
'source' => 'schedule',
'role' => 'مدرب مساعد',
];
}
}
// If no schedule-level trainer, fall back to group head trainer
if (empty($trainers) && $group->head_trainer_id) {
$trainers[] = [
'id' => $group->head_trainer_id,
'source' => 'group_head',
'role' => 'مدرب رئيسي للمجموعة',
];
}
return $trainers;
}
/**
* Full validation before assignment. Returns all issues (blocking + warnings).
*/
public function validateAssignment(
string $entityType, // 'group' or 'trainer'
int $entityId,
int $facilityId,
string $date,
string $startTime,
string $endTime,
array $segmentIds,
?int $excludeScheduleId = null
): array {
$errors = [];
$warnings = [];
if ($entityType === 'group') {
// Check group conflicts (can't be in two places)
$groupConflicts = $this->checkGroupConflicts($entityId, $date, $startTime, $endTime, $excludeScheduleId);
foreach ($groupConflicts as $c) {
// If same facility, it might just be a re-assignment — warning not error
if (isset($c['facility']) && $c['source'] === 'space_reservation') {
$warnings[] = $c;
} else {
$errors[] = $c;
}
}
// Check implied trainers for conflicts
$impliedTrainers = $this->getImpliedTrainers($entityId, $date, $startTime, $endTime);
foreach ($impliedTrainers as $trainer) {
$trainerConflicts = $this->checkTrainerConflicts(
$trainer['id'],
$date,
$startTime,
$endTime,
$excludeScheduleId
);
foreach ($trainerConflicts as $tc) {
$warnings[] = array_merge($tc, [
'implied_trainer' => true,
'trainer_role' => $trainer['role'],
'trainer_id' => $trainer['id'],
]);
}
}
} elseif ($entityType === 'trainer') {
$trainerConflicts = $this->checkTrainerConflicts($entityId, $date, $startTime, $endTime, $excludeScheduleId);
foreach ($trainerConflicts as $c) {
$errors[] = $c;
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* Get all occupied time ranges for a trainer on a given date.
* Useful for showing trainer availability in the UI.
*/
public function getTrainerOccupiedSlots(int $trainerId, string $date): array
{
$dayOfWeek = Carbon::parse($date)->dayOfWeek;
$slots = [];
// From schedules
$schedules = TrainingSchedule::where('is_active', true)
->where('day_of_week', $dayOfWeek)
->where(function ($q) use ($trainerId) {
$q->where('trainer_id', $trainerId)
->orWhere('assistant_trainer_id', $trainerId);
})
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->with('group')
->get();
foreach ($schedules as $s) {
$slots[] = [
'start' => substr($s->start_time, 0, 5),
'end' => substr($s->end_time, 0, 5),
'title' => $s->group?->name_ar ?? '',
'type' => 'schedule',
];
}
// From head_trainer groups
$headSchedules = TrainingSchedule::where('is_active', true)
->where('day_of_week', $dayOfWeek)
->whereNull('trainer_id')
->whereHas('group', fn ($q) => $q->where('head_trainer_id', $trainerId))
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->with('group')
->get();
foreach ($headSchedules as $s) {
$slots[] = [
'start' => substr($s->start_time, 0, 5),
'end' => substr($s->end_time, 0, 5),
'title' => $s->group?->name_ar ?? '',
'type' => 'head_trainer',
];
}
// From sessions
$sessions = TrainingSession::where('session_date', $date)
->whereIn('status', ['scheduled', 'in_progress'])
->where(function ($q) use ($trainerId) {
$q->where('trainer_id', $trainerId)
->orWhere('assistant_trainer_id', $trainerId);
})
->with('group')
->get();
foreach ($sessions as $s) {
$slots[] = [
'start' => substr($s->start_time, 0, 5),
'end' => substr($s->end_time, 0, 5),
'title' => $s->group?->name_ar ?? '',
'type' => 'session',
];
}
// Sort by start time
usort($slots, fn ($a, $b) => strcmp($a['start'], $b['start']));
return $slots;
}
/**
* Get all occupied time ranges for a group on a given date.
*/
public function getGroupOccupiedSlots(int $groupId, string $date): array
{
$dayOfWeek = Carbon::parse($date)->dayOfWeek;
$slots = [];
$schedules = TrainingSchedule::where('training_group_id', $groupId)
->where('is_active', true)
->where('day_of_week', $dayOfWeek)
->where(function ($q) use ($date) {
$q->where(function ($q2) use ($date) {
$q2->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})->where(function ($q2) use ($date) {
$q2->whereNull('effective_until')->orWhere('effective_until', '>=', $date);
});
})
->with('facility')
->get();
foreach ($schedules as $s) {
$slots[] = [
'start' => substr($s->start_time, 0, 5),
'end' => substr($s->end_time, 0, 5),
'facility' => $s->facility?->name_ar ?? '',
'type' => 'schedule',
];
}
$sessions = TrainingSession::where('training_group_id', $groupId)
->where('session_date', $date)
->whereIn('status', ['scheduled', 'in_progress'])
->with('facility')
->get();
foreach ($sessions as $s) {
$slots[] = [
'start' => substr($s->start_time, 0, 5),
'end' => substr($s->end_time, 0, 5),
'facility' => $s->facility?->name_ar ?? '',
'type' => 'session',
];
}
usort($slots, fn ($a, $b) => strcmp($a['start'], $b['start']));
return $slots;
}
/**
* Check if a time range overlaps with facility operating hours.
*/
public function isWithinOperatingHours(string $startTime, string $endTime, string $opStart, string $opEnd): bool
{
return $startTime >= $opStart && $endTime <= $opEnd;
}
/**
* Get minimum gap between consecutive sessions (configurable per academy, default 0).
*/
public function getMinGapMinutes(): int
{
return (int) (app('current_academy')?->getSetting('min_session_gap_minutes') ?? 0);
}
}
......@@ -5,16 +5,14 @@
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\Scheduling\Services\ScheduleConflictService;
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;
......@@ -29,21 +27,28 @@ class VisualScheduleBuilder extends Component
#[Url]
public string $selectedDay = '';
// Flexible time (to the minute)
public string $selectedSlotStart = '';
public string $selectedSlotEnd = '';
public string $customStartTime = '';
public string $customEndTime = '';
public bool $useCustomTime = false;
// Grid state
public array $gridSegments = [];
public array $assignments = [];
public array $conflicts = [];
public array $warnings = [];
// Recurring booking
public bool $showRecurring = false;
public int $recurringWeeks = 8;
public array $recurringDays = [];
public bool $skipConflicts = true;
// Trainer assignment
public string $trainerSearch = '';
// Trainer availability cache (for the sidebar indicators)
public array $trainerAvailability = [];
public array $groupAvailability = [];
public function mount(): void
{
......@@ -57,67 +62,105 @@ public function mount(): void
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();
}
// ─── Navigation ──────────────────────────────────────────────
public function selectDay(string $date): void
{
$this->selectedDay = $date;
$this->resetGrid();
$this->refreshAvailability();
}
public function openSlot(string $start, string $end): void
{
$this->selectedSlotStart = $start;
$this->selectedSlotEnd = $end;
$this->customStartTime = $start;
$this->customEndTime = $end;
$this->useCustomTime = false;
$this->loadGridForSlot();
$this->refreshAvailability();
}
public function openCustomTime(): void
{
if (!$this->customStartTime || !$this->customEndTime) {
$this->addError('time', __('يجب تحديد وقت البداية والنهاية'));
return;
}
if ($this->customStartTime >= $this->customEndTime) {
$this->addError('time', __('وقت البداية يجب أن يكون قبل وقت النهاية'));
return;
}
$facility = Facility::find($this->facility_id);
if ($facility) {
$opStart = $facility->operating_start ?? '00:00';
$opEnd = $facility->operating_end ?? '23:59';
if ($this->customStartTime < $opStart || $this->customEndTime > $opEnd) {
$this->addError('time', __('الوقت المحدد خارج ساعات عمل المنشأة') . " ({$opStart} - {$opEnd})");
return;
}
}
$this->selectedSlotStart = $this->customStartTime;
$this->selectedSlotEnd = $this->customEndTime;
$this->useCustomTime = true;
$this->loadGridForSlot();
$this->refreshAvailability();
}
public function closeSlot(): void
{
$this->selectedSlotStart = '';
$this->selectedSlotEnd = '';
$this->customStartTime = '';
$this->customEndTime = '';
$this->useCustomTime = false;
$this->gridSegments = [];
$this->assignments = [];
$this->conflicts = [];
$this->warnings = [];
}
// ─── Assignment Actions ──────────────────────────────────────
public function assignGroupToSegments(int $groupId, array $segmentIds): void
{
if (empty($segmentIds) || !$this->facility_id || !$this->selectedSlotStart) {
return;
}
$this->conflicts = [];
$this->warnings = [];
$facility = Facility::find($this->facility_id);
if (!$facility) return;
// 1. Check space collision (physical segments overlap)
$collisionService = app(SpaceCollisionService::class);
$conflicts = $collisionService->check(
$spaceConflicts = $collisionService->check(
$facility->id,
$this->selectedDay,
$this->selectedSlotStart,
......@@ -125,37 +168,81 @@ public function assignGroupToSegments(int $groupId, array $segmentIds): void
$segmentIds
);
if (!empty($conflicts)) {
$this->conflicts = $conflicts;
if (!empty($spaceConflicts)) {
$this->conflicts = array_map(fn ($c) => array_merge($c, ['category' => 'space']), $spaceConflicts);
return;
}
// Check group isn't already assigned in this slot (anywhere)
// 2. Check group isn't already assigned in this slot (in current unsaved assignments)
foreach ($this->assignments as $assignment) {
if (($assignment['group_id'] ?? null) === $groupId) {
if (($assignment['group_id'] ?? null) === $groupId && ($assignment['type'] ?? '') !== 'existing') {
$this->addError('assignment', __('هذه المجموعة معيّنة بالفعل في هذا الوقت'));
return;
}
}
// 3. Check group conflicts (can't be in two places at once)
$conflictService = app(ScheduleConflictService::class);
$validation = $conflictService->validateAssignment(
'group',
$groupId,
$facility->id,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd,
$segmentIds
);
if (!$validation['valid']) {
$this->conflicts = array_map(fn ($c) => array_merge($c, ['category' => 'group']), $validation['errors']);
return;
}
// Warnings are non-blocking but shown to user
if (!empty($validation['warnings'])) {
$this->warnings = $validation['warnings'];
}
// 4. Add to pending assignments
$group = TrainingGroup::with('headTrainer')->find($groupId);
$this->assignments[] = [
'type' => 'group',
'group_id' => $groupId,
'group_name' => $group?->name_ar ?? '',
'segment_ids' => $segmentIds,
'implied_trainer' => $group?->headTrainer?->name_ar ?? $group?->headTrainer?->name,
'implied_trainer_id' => $group?->head_trainer_id,
];
$this->conflicts = [];
}
public function assignTrainerToSegments(int $trainerId, array $segmentIds): void
{
if (empty($segmentIds) || !$this->facility_id) {
if (empty($segmentIds) || !$this->facility_id || !$this->selectedSlotStart) {
return;
}
$this->conflicts = [];
$this->warnings = [];
// Check trainer conflicts
$conflictService = app(ScheduleConflictService::class);
$trainerConflicts = $conflictService->checkTrainerConflicts(
$trainerId,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd
);
if (!empty($trainerConflicts)) {
$this->conflicts = array_map(fn ($c) => array_merge($c, ['category' => 'trainer']), $trainerConflicts);
return;
}
$trainer = User::find($trainerId);
$this->assignments[] = [
'type' => 'trainer',
'trainer_id' => $trainerId,
'trainer_name' => $trainer?->name_ar ?? $trainer?->name ?? '',
'segment_ids' => $segmentIds,
];
}
......@@ -164,11 +251,28 @@ public function removeAssignment(int $index): void
{
unset($this->assignments[$index]);
$this->assignments = array_values($this->assignments);
$this->conflicts = [];
$this->warnings = [];
}
public function dismissWarnings(): void
{
$this->warnings = [];
}
public function dismissConflicts(): void
{
$this->conflicts = [];
}
// ─── Save Operations ─────────────────────────────────────────
public function saveAssignments(): void
{
if (empty($this->assignments) || !$this->facility_id || !$this->selectedSlotStart) {
$newAssignments = collect($this->assignments)->filter(fn ($a) => ($a['type'] ?? '') !== 'existing');
if ($newAssignments->isEmpty() || !$this->facility_id || !$this->selectedSlotStart) {
$this->addError('save', __('لا يوجد تعيينات جديدة للحفظ'));
return;
}
......@@ -177,61 +281,44 @@ public function saveAssignments(): void
$layout = $this->resolveLayout($facility);
$dayOfWeek = Carbon::parse($this->selectedDay)->dayOfWeek;
$conflictService = app(ScheduleConflictService::class);
// Final validation pass before commit
foreach ($newAssignments as $assignment) {
if ($assignment['type'] === 'group') {
$validation = $conflictService->validateAssignment(
'group',
$assignment['group_id'],
$facility->id,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd,
$assignment['segment_ids']
);
if (!$validation['valid']) {
$this->conflicts = $validation['errors'];
return;
}
} elseif ($assignment['type'] === 'trainer') {
$trainerConflicts = $conflictService->checkTrainerConflicts(
$assignment['trainer_id'],
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd
);
if (!empty($trainerConflicts)) {
$this->conflicts = $trainerConflicts;
return;
}
}
}
DB::transaction(function () use ($facility, $layout, $dayOfWeek) {
foreach ($this->assignments as $assignment) {
DB::transaction(function () use ($facility, $layout, $dayOfWeek, $newAssignments) {
foreach ($newAssignments 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(),
]);
$this->persistGroupAssignment($assignment, $facility, $layout, $dayOfWeek);
} 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->persistTrainerAssignment($assignment, $facility, $dayOfWeek);
}
}
});
......@@ -239,11 +326,14 @@ public function saveAssignments(): void
$this->dispatch('assignment-saved');
session()->flash('success', __('تم حفظ الجدول بنجاح'));
$this->loadGridForSlot();
$this->refreshAvailability();
}
public function saveRecurring(): void
{
if (empty($this->assignments) || !$this->facility_id || !$this->selectedSlotStart) {
$newAssignments = collect($this->assignments)->filter(fn ($a) => ($a['type'] ?? '') !== 'existing');
if ($newAssignments->isEmpty() || !$this->facility_id || !$this->selectedSlotStart) {
return;
}
......@@ -254,20 +344,25 @@ public function saveRecurring(): void
$startDate = Carbon::parse($this->selectedDay);
$dayOfWeek = $startDate->dayOfWeek;
$collisionService = app(SpaceCollisionService::class);
$conflictService = app(ScheduleConflictService::class);
$days = !empty($this->recurringDays) ? $this->recurringDays : [$dayOfWeek];
$days = !empty($this->recurringDays) ? array_map('intval', $this->recurringDays) : [$dayOfWeek];
$bookedCount = 0;
$conflictDates = [];
DB::transaction(function () use ($facility, $layout, $startDate, $days, $collisionService, &$bookedCount, &$conflictDates) {
foreach ($this->assignments as $assignment) {
$trainerConflictDates = [];
DB::transaction(function () use (
$facility, $layout, $startDate, $days,
$collisionService, $conflictService,
$newAssignments, &$bookedCount, &$conflictDates, &$trainerConflictDates
) {
foreach ($newAssignments 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,
......@@ -285,18 +380,16 @@ public function saveRecurring(): void
]
);
// Book each week
for ($week = 0; $week < $this->recurringWeeks; $week++) {
$date = $startDate->copy()->addWeeks($week);
// Adjust to the correct day
while ($date->dayOfWeek !== $targetDay) {
while ($date->dayOfWeek !== (int) $targetDay) {
$date->addDay();
}
$dateStr = $date->toDateString();
// Check conflicts
$conflicts = $collisionService->check(
// Space collision check
$spaceConflicts = $collisionService->check(
$facility->id,
$dateStr,
$this->selectedSlotStart,
......@@ -304,11 +397,44 @@ public function saveRecurring(): void
$assignment['segment_ids']
);
if (!empty($conflicts)) {
if (!empty($spaceConflicts)) {
if ($this->skipConflicts) {
$conflictDates[] = $dateStr;
continue;
}
$conflictDates[] = $dateStr;
continue;
}
// Group conflict check (can't be in two places)
$groupConflicts = $conflictService->checkGroupConflicts(
$group->id,
$dateStr,
$this->selectedSlotStart,
$this->selectedSlotEnd,
$schedule->id
);
if (!empty($groupConflicts)) {
$conflictDates[] = $dateStr;
continue;
}
// Trainer conflict check (implied trainer from group)
if ($group->head_trainer_id) {
$tc = $conflictService->checkTrainerConflicts(
$group->head_trainer_id,
$dateStr,
$this->selectedSlotStart,
$this->selectedSlotEnd,
$schedule->id
);
if (!empty($tc)) {
$trainerConflictDates[] = $dateStr;
// Don't block — warn only. Trainer conflicts on recurring are soft.
}
}
SpaceReservation::create([
'academy_id' => $group->academy_id,
'facility_id' => $facility->id,
......@@ -335,14 +461,20 @@ public function saveRecurring(): void
$msg = __('تم حجز') . " {$bookedCount} " . __('جلسة بنجاح');
if (!empty($conflictDates)) {
$msg .= '. ' . __('تم تخطي') . ' ' . count($conflictDates) . ' ' . __('تاريخ بسبب تعارضات');
$msg .= '. ' . __('تم تخطي') . ' ' . count($conflictDates) . ' ' . __('تاريخ بسبب تعارضات مكانية');
}
if (!empty($trainerConflictDates)) {
$msg .= '. ' . __('تحذير: تعارض مدرب في') . ' ' . count($trainerConflictDates) . ' ' . __('تاريخ');
}
session()->flash('success', $msg);
$this->showRecurring = false;
$this->loadGridForSlot();
$this->refreshAvailability();
}
// ─── Cancel Operations ───────────────────────────────────────
public function cancelReservation(int $reservationId): void
{
$reservation = SpaceReservation::find($reservationId);
......@@ -355,6 +487,7 @@ public function cancelReservation(int $reservationId): void
]);
$this->loadGridForSlot();
$this->refreshAvailability();
session()->flash('success', __('تم إلغاء الحجز'));
}
......@@ -363,6 +496,12 @@ public function cancelSeries(int $reservationId): void
$reservation = SpaceReservation::find($reservationId);
if (!$reservation || !$reservation->reservable_id) return;
$count = SpaceReservation::where('reservable_type', $reservation->reservable_type)
->where('reservable_id', $reservation->reservable_id)
->where('reservation_date', '>=', now()->toDateString())
->where('status', 'confirmed')
->count();
SpaceReservation::where('reservable_type', $reservation->reservable_type)
->where('reservable_id', $reservation->reservable_id)
->where('reservation_date', '>=', now()->toDateString())
......@@ -373,19 +512,185 @@ public function cancelSeries(int $reservationId): void
'cancelled_at' => now(),
]);
// Deactivate the schedule if all future reservations are cancelled
if ($reservation->reservable_type === TrainingSchedule::class) {
$schedule = TrainingSchedule::find($reservation->reservable_id);
if ($schedule) {
$schedule->update(['is_active' => false, 'effective_until' => now()->toDateString()]);
}
}
$this->loadGridForSlot();
session()->flash('success', __('تم إلغاء السلسلة بالكامل'));
$this->refreshAvailability();
session()->flash('success', __('تم إلغاء') . " {$count} " . __('حجز مستقبلي'));
}
// ─── Availability Check (for sidebar indicators) ─────────────
public function checkTrainerAvailability(int $trainerId): array
{
if (!$this->selectedSlotStart || !$this->selectedDay) return [];
$conflictService = app(ScheduleConflictService::class);
return $conflictService->checkTrainerConflicts(
$trainerId,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd
);
}
public function getTrainerScheduleForDay(int $trainerId): array
{
$conflictService = app(ScheduleConflictService::class);
return $conflictService->getTrainerOccupiedSlots($trainerId, $this->selectedDay);
}
public function getGroupScheduleForDay(int $groupId): array
{
$conflictService = app(ScheduleConflictService::class);
return $conflictService->getGroupOccupiedSlots($groupId, $this->selectedDay);
}
// ─── Private Helpers ─────────────────────────────────────────
private function refreshAvailability(): void
{
if (!$this->selectedSlotStart || !$this->selectedDay) {
$this->trainerAvailability = [];
$this->groupAvailability = [];
return;
}
$conflictService = app(ScheduleConflictService::class);
// Check each trainer
$trainers = User::where('status', 'active')
->whereHas('primaryRole', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer']))
->pluck('id');
$this->trainerAvailability = [];
foreach ($trainers as $trainerId) {
$conflicts = $conflictService->checkTrainerConflicts(
$trainerId,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd
);
$this->trainerAvailability[$trainerId] = [
'available' => empty($conflicts),
'conflicts' => $conflicts,
];
}
// Check each active group
$groups = TrainingGroup::whereIn('status', ['active', 'forming', 'full'])->pluck('id');
$this->groupAvailability = [];
foreach ($groups as $groupId) {
$conflicts = $conflictService->checkGroupConflicts(
$groupId,
$this->selectedDay,
$this->selectedSlotStart,
$this->selectedSlotEnd
);
$this->groupAvailability[$groupId] = [
'available' => empty($conflicts),
'conflicts' => $conflicts,
];
}
}
private function persistGroupAssignment(array $assignment, Facility $facility, ?SpaceLayout $layout, int $dayOfWeek): void
{
$group = TrainingGroup::find($assignment['group_id']);
if (!$group) return;
$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,
]
);
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(),
]);
}
private function persistTrainerAssignment(array $assignment, Facility $facility, int $dayOfWeek): void
{
// Find schedule(s) in this slot and attach trainer
$schedules = TrainingSchedule::where('facility_id', $facility->id)
->where('day_of_week', $dayOfWeek)
->where('start_time', $this->selectedSlotStart)
->where('end_time', $this->selectedSlotEnd)
->where('is_active', true)
->get();
if ($schedules->isEmpty()) {
// No group schedule at this time — create a standalone reservation for the trainer
SpaceReservation::create([
'academy_id' => app('current_academy')?->id,
'facility_id' => $facility->id,
'space_layout_id' => null,
'status' => 'confirmed',
'reservation_date' => $this->selectedDay,
'start_time' => $this->selectedSlotStart,
'end_time' => $this->selectedSlotEnd,
'segment_ids' => $assignment['segment_ids'],
'title' => ($assignment['trainer_name'] ?? __('مدرب')) . ' — ' . __('إشراف'),
'created_by' => auth()->id(),
]);
return;
}
// Attach to the relevant schedule(s) that overlap with the trainer's segments
foreach ($schedules as $schedule) {
$scheduleSegments = $schedule->space_reservation_template ?? [];
$overlap = array_intersect($assignment['segment_ids'], $scheduleSegments);
if (!empty($overlap) || empty($scheduleSegments)) {
if (!$schedule->trainer_id) {
$schedule->update(['trainer_id' => $assignment['trainer_id']]);
} elseif (!$schedule->assistant_trainer_id) {
$schedule->update(['assistant_trainer_id' => $assignment['trainer_id']]);
}
}
}
}
private function resetGrid(): void
{
$this->selectedSlotStart = '';
$this->selectedSlotEnd = '';
$this->customStartTime = '';
$this->customEndTime = '';
$this->useCustomTime = false;
$this->gridSegments = [];
$this->assignments = [];
$this->conflicts = [];
$this->warnings = [];
}
private function loadGridForSlot(): void
......@@ -422,6 +727,11 @@ private function loadGridForSlot(): void
->get();
foreach ($reservations as $res) {
$trainerName = null;
if ($res->reservable && $res->reservable instanceof TrainingSchedule) {
$trainerName = $res->reservable->trainer?->name_ar ?? $res->reservable->trainer?->name;
}
$this->assignments[] = [
'type' => 'existing',
'reservation_id' => $res->id,
......@@ -430,6 +740,8 @@ private function loadGridForSlot(): void
'is_recurring' => $res->is_recurring,
'reservable_type' => $res->reservable_type,
'reservable_id' => $res->reservable_id,
'trainer_name' => $trainerName,
'time' => substr($res->start_time, 0, 5) . ' - ' . substr($res->end_time, 0, 5),
];
}
}
......@@ -487,7 +799,7 @@ private function resolveLayout(?Facility $facility): ?SpaceLayout
$dayOfWeek = Carbon::parse($this->selectedDay)->dayOfWeek;
// Try specific date first
// Date-specific override first
$layout = SpaceLayout::where('facility_id', $facility->id)
->where('is_active', true)
->where('is_recurring', false)
......@@ -496,7 +808,7 @@ private function resolveLayout(?Facility $facility): ?SpaceLayout
if ($layout) return $layout;
// Fall back to recurring for this day
// Recurring for this day
$layout = SpaceLayout::where('facility_id', $facility->id)
->where('is_active', true)
->where('is_recurring', true)
......@@ -505,7 +817,7 @@ private function resolveLayout(?Facility $facility): ?SpaceLayout
if ($layout) return $layout;
// Fall back to any active layout
// Any active layout as fallback
return SpaceLayout::where('facility_id', $facility->id)
->where('is_active', true)
->orderBy('sort_order')
......@@ -526,7 +838,10 @@ private function getAvailableGroups(): array
'count' => $g->current_count,
'max' => $g->max_capacity,
'trainer' => $g->headTrainer?->name_ar ?? $g->headTrainer?->name,
'trainer_id' => $g->head_trainer_id,
'color' => $this->groupColor($g->id),
'available' => $this->groupAvailability[$g->id]['available'] ?? true,
'conflict_reason' => $this->groupAvailability[$g->id]['conflicts'][0]['message'] ?? null,
])
->toArray();
}
......@@ -540,6 +855,8 @@ private function getAvailableTrainers(): array
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name_ar ?? $u->name,
'available' => $this->trainerAvailability[$u->id]['available'] ?? true,
'conflict_reason' => $this->trainerAvailability[$u->id]['conflicts'][0]['message'] ?? null,
])
->toArray();
}
......
......@@ -30,15 +30,23 @@ class="w-full text-sm border-gray-300 rounded-lg px-3 py-1.5 focus:ring-blue-500
{{-- 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"
<div draggable="{{ $group['available'] ? 'true' : 'false' }}"
@if($group['available'])
@dragstart="startDrag($event, 'group', {{ json_encode($group) }})"
@dragend="endDrag()"
@endif
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="border rounded-lg p-2.5 transition-colors
{{ $group['available'] ? 'border-gray-200 cursor-grab active:cursor-grabbing hover:border-blue-300 hover:bg-blue-50/50' : 'border-red-200 bg-red-50/30 cursor-not-allowed opacity-70' }}"
: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>
@if(!$group['available'])
<span class="ms-auto shrink-0 w-4 h-4 text-red-500" title="{{ $group['conflict_reason'] }}">
<svg 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 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
</span>
@endif
</div>
<div class="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{{ $group['program'] }}</span>
......@@ -46,7 +54,13 @@ class="border border-gray-200 rounded-lg p-2.5 cursor-grab active:cursor-grabbin
<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>
<div class="mt-1 text-xs text-gray-400 truncate">
<svg class="w-3 h-3 inline" 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>
{{ $group['trainer'] }}
</div>
@endif
@if(!$group['available'] && $group['conflict_reason'])
<p class="mt-1 text-[10px] text-red-500 truncate">{{ $group['conflict_reason'] }}</p>
@endif
</div>
@endforeach
......@@ -59,17 +73,28 @@ class="border border-gray-200 rounded-lg p-2.5 cursor-grab active:cursor-grabbin
{{-- 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"
<div draggable="{{ $trainer['available'] ? 'true' : 'false' }}"
@if($trainer['available'])
@dragstart="startDrag($event, 'trainer', {{ json_encode($trainer) }})"
@dragend="endDrag()"
@endif
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">
class="border rounded-lg p-2.5 transition-colors
{{ $trainer['available'] ? 'border-gray-200 cursor-grab active:cursor-grabbing hover:border-purple-300 hover:bg-purple-50/50' : 'border-orange-200 bg-orange-50/30 cursor-not-allowed opacity-70' }}">
<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">
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0 {{ $trainer['available'] ? 'bg-purple-100 text-purple-600' : 'bg-orange-100 text-orange-600' }}">
<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>
@if(!$trainer['available'])
<span class="ms-auto text-[10px] text-orange-600 font-medium">{{ __('مشغول') }}</span>
@else
<span class="ms-auto w-2 h-2 rounded-full bg-green-500"></span>
@endif
</div>
@if(!$trainer['available'] && $trainer['conflict_reason'])
<p class="mt-1 text-[10px] text-orange-600 truncate ms-8">{{ $trainer['conflict_reason'] }}</p>
@endif
</div>
@endforeach
</div>
......@@ -112,21 +137,57 @@ class="w-9 h-9 rounded-lg text-xs font-medium {{ $date->toDateString() === $sele
</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
{{-- Alerts --}}
<div class="shrink-0">
@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 flex items-center justify-between">
<span>{{ session('success') }}</span>
</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
@if(!empty($conflicts))
<div class="mx-4 mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-bold text-red-700">{{ __('تعارض — لا يمكن التعيين') }}</span>
<button wire:click="dismissConflicts" class="text-red-400 hover:text-red-600 text-xs">✕</button>
</div>
@foreach($conflicts as $c)
<p class="text-xs text-red-600 mt-1">
<span class="font-medium">{{ $c['title'] ?? $c['message'] ?? __('تعارض') }}</span>
@if(isset($c['time'])) <span class="text-red-400" dir="ltr">({{ $c['time'] }})</span> @endif
@if(isset($c['message'])) — {{ $c['message'] }} @endif
</p>
@endforeach
</div>
@endif
@if(!empty($warnings))
<div class="mx-4 mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-bold text-amber-700">{{ __('تحذيرات (غير مانعة)') }}</span>
<button wire:click="dismissWarnings" class="text-amber-400 hover:text-amber-600 text-xs">✕</button>
</div>
@foreach($warnings as $w)
<p class="text-xs text-amber-600 mt-1">
@if(isset($w['implied_trainer']))
<svg class="w-3 h-3 inline" 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>
@endif
{{ $w['message'] ?? '' }}
</p>
@endforeach
</div>
@endif
@error('time')
<div class="mx-4 mt-3 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">{{ $message }}</div>
@enderror
@error('save')
<div class="mx-4 mt-3 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">{{ $message }}</div>
@enderror
@error('assignment')
<div class="mx-4 mt-3 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">{{ $message }}</div>
@enderror
</div>
@endif
{{-- Content Area --}}
@if(!$facility)
......@@ -138,8 +199,26 @@ class="w-9 h-9 rounded-lg text-xs font-medium {{ $date->toDateString() === $sele
</div>
</div>
@elseif(!$selectedSlotStart)
{{-- Time Slots Grid --}}
{{-- Time Slots Grid + Custom Time Picker --}}
<div class="flex-1 overflow-y-auto p-4">
{{-- Custom time picker --}}
<div class="mb-4 bg-white border border-gray-200 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm font-medium text-gray-700">{{ __('وقت مخصص:') }}</span>
<div class="flex items-center gap-2">
<input type="time" wire:model="customStartTime" class="border-gray-300 rounded-lg text-sm px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500 w-28" dir="ltr" step="300">
<span class="text-gray-400">—</span>
<input type="time" wire:model="customEndTime" class="border-gray-300 rounded-lg text-sm px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500 w-28" dir="ltr" step="300">
</div>
<button wire:click="openCustomTime" 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="openCustomTime">
{{ __('فتح') }}
</button>
<span class="text-xs text-gray-400">{{ __('أو اختر من الأوقات أدناه') }}</span>
</div>
</div>
{{-- Hourly slots --}}
<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
......@@ -189,15 +268,24 @@ class="relative border rounded-xl p-4 text-start transition-all hover:shadow-md
<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>
@if($useCustomTime)
<span class="text-[10px] bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded ms-2">{{ __('وقت مخصص') }}</span>
@endif
</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">
@php $newCount = collect($assignments)->filter(fn($a) => ($a['type'] ?? '') !== 'existing')->count(); @endphp
@if($newCount > 0)
<span class="text-xs text-gray-500">{{ $newCount }} {{ __('جديد') }}</span>
@endif
<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"
@if($newCount === 0) disabled title="{{ __('أضف مجموعات أولاً') }}" @endif>
<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">
<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 disabled:opacity-50"
wire:loading.attr="disabled" wire:target="saveAssignments"
@if($newCount === 0) disabled @endif>
<span wire:loading.remove wire:target="saveAssignments">{{ __('حفظ') }}</span>
<span wire:loading wire:target="saveAssignments">{{ __('جارٍ الحفظ...') }}</span>
</button>
......@@ -222,6 +310,8 @@ class="relative border rounded-xl p-4 text-start transition-all hover:shadow-md
$segAssignment = collect($assignments)->first(fn($a) => in_array($seg['id'], $a['segment_ids'] ?? []));
$isOccupied = $segAssignment !== null;
$isExisting = $isOccupied && ($segAssignment['type'] ?? '') === 'existing';
$isNewGroup = $isOccupied && ($segAssignment['type'] ?? '') === 'group';
$isNewTrainer = $isOccupied && ($segAssignment['type'] ?? '') === 'trainer';
@endphp
<div
@dragover.prevent="highlightSegment({{ $seg['id'] }})"
......@@ -235,6 +325,8 @@ class="relative border rounded-xl p-4 text-start transition-all hover:shadow-md
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' : '' }}
{{ $isNewGroup ? 'border-green-300 bg-green-50' : '' }}
{{ $isNewTrainer ? 'border-purple-300 bg-purple-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
>
......@@ -246,10 +338,16 @@ class="relative border-2 rounded-xl p-3 min-h-[120px] flex flex-col transition-a
@endif
</div>
{{-- Content --}}
{{-- Existing reservation --}}
@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['trainer_name'] ?? null)
<p class="text-[10px] text-blue-600 mt-0.5 truncate">
<svg class="w-3 h-3 inline" 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>
{{ $segAssignment['trainer_name'] }}
</p>
@endif
@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>
......@@ -257,7 +355,6 @@ class="relative border-2 rounded-xl p-3 min-h-[120px] flex flex-col transition-a
</span>
@endif
</div>
{{-- Cancel buttons --}}
<div class="flex gap-1 mt-2">
<button wire:click="cancelReservation({{ $segAssignment['reservation_id'] }})"
wire:confirm="{{ __('إلغاء هذا الحجز؟') }}"
......@@ -268,14 +365,46 @@ class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إل
class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إلغاء السلسلة') }}</button>
@endif
</div>
{{-- New group assignment (unsaved) --}}
@elseif($isNewGroup)
<div class="flex-1 flex flex-col justify-center">
<p class="text-sm font-semibold text-green-800 truncate">{{ $segAssignment['group_name'] ?? '' }}</p>
@if($segAssignment['implied_trainer'] ?? null)
<p class="text-[10px] text-green-600 mt-0.5 truncate">
<svg class="w-3 h-3 inline" 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>
{{ $segAssignment['implied_trainer'] }} ({{ __('تلقائي') }})
</p>
@endif
<span class="inline-flex items-center mt-1 text-[10px] text-green-600">{{ __('جديد — لم يُحفظ بعد') }}</span>
</div>
@php $assignIdx = collect($assignments)->search(fn($a) => ($a['type'] ?? '') === 'group' && ($a['group_id'] ?? null) === ($segAssignment['group_id'] ?? -1)); @endphp
@if($assignIdx !== false)
<button wire:click="removeAssignment({{ $assignIdx }})" class="text-[10px] text-red-500 hover:text-red-700 hover:underline mt-1">{{ __('إزالة') }}</button>
@endif
{{-- New trainer assignment (unsaved) --}}
@elseif($isNewTrainer)
<div class="flex-1 flex flex-col justify-center">
<p class="text-sm font-semibold text-purple-800 truncate">{{ $segAssignment['trainer_name'] ?? '' }}</p>
<span class="inline-flex items-center mt-1 text-[10px] text-purple-600">{{ __('مدرب — لم يُحفظ بعد') }}</span>
</div>
@php $assignIdx = collect($assignments)->search(fn($a) => ($a['type'] ?? '') === 'trainer' && ($a['trainer_id'] ?? null) === ($segAssignment['trainer_id'] ?? -1)); @endphp
@if($assignIdx !== false)
<button wire:click="removeAssignment({{ $assignIdx }})" class="text-[10px] text-red-500 hover:text-red-700 hover:underline mt-1">{{ __('إزالة') }}</button>
@endif
{{-- Unavailable --}}
@elseif(!$seg['available'])
<div class="flex-1 flex items-center justify-center">
<span class="text-xs text-gray-400">{{ __('غير متاح') }}</span>
</div>
{{-- Empty / available --}}
@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>
<span class="text-xs text-blue-600 font-medium" x-show="selectedSegments.includes({{ $seg['id'] }})">{{ __('محدد') }}</span>
</div>
@endif
</div>
......@@ -286,17 +415,24 @@ class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إل
<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-green-100 border border-green-300 rounded"></span> {{ __('جديد (لم يُحفظ)') }}</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 bg-purple-100 border border-purple-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 --}}
{{-- Multi-select helper --}}
<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>
<p class="text-xs text-blue-600 mb-3">{{ __('اسحب مجموعة أو مدرب على أي خلية محددة لتعيينهم جميعاً') }}</p>
<div class="flex gap-2">
<button @click="selectedSegments = []" class="text-xs text-gray-500 hover:text-gray-700 border border-gray-300 px-2 py-1 rounded">{{ __('إلغاء التحديد') }}</button>
<button @click="selectRow()" class="text-xs text-blue-600 hover:text-blue-700 border border-blue-300 px-2 py-1 rounded">{{ __('تحديد الصف') }}</button>
<button @click="selectCol()" class="text-xs text-blue-600 hover:text-blue-700 border border-blue-300 px-2 py-1 rounded">{{ __('تحديد العمود') }}</button>
<button @click="selectAll()" class="text-xs text-blue-600 hover:text-blue-700 border border-blue-300 px-2 py-1 rounded">{{ __('تحديد الكل') }}</button>
</div>
</div>
@else
<div class="text-center py-12">
......@@ -326,7 +462,7 @@ class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إل
<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">
<input type="range" wire:model.live="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">
......@@ -351,15 +487,24 @@ class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إل
<p class="text-xs text-gray-400 mt-1">{{ __('اترك فارغاً للتكرار في نفس اليوم فقط') }}</p>
</div>
{{-- Conflict handling --}}
<div class="flex items-center gap-2">
<input type="checkbox" wire:model="skipConflicts" id="skipConflicts" class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="skipConflicts" class="text-sm text-gray-700">{{ __('تخطي التواريخ المتعارضة تلقائياً') }}</label>
</div>
{{-- Summary --}}
<div class="bg-gray-50 rounded-lg p-3 text-sm">
@php $newCount = collect($assignments)->filter(fn($a) => ($a['type'] ?? '') !== 'existing')->count(); @endphp
<p class="text-gray-600">
{{ __('سيتم حجز') }}
<span class="font-bold">{{ count($assignments) }}</span> {{ __('مجموعة') }}
<span class="font-bold">{{ $newCount }}</span> {{ __('تعيين') }}
{{ __('لمدة') }}
<span class="font-bold">{{ $recurringWeeks }}</span> {{ __('أسبوع') }}
= {{ __('حتى') }}
<span class="font-bold">{{ $newCount * $recurringWeeks }}</span> {{ __('حجز') }}
</p>
<p class="text-xs text-gray-400 mt-1">{{ __('سيتم تخطي التواريخ التي بها تعارضات تلقائياً') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('سيتم فحص التعارضات للمدربين والمجموعات والمساحات') }}</p>
</div>
</div>
......@@ -386,6 +531,10 @@ class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إل
highlightedSegment: null,
startDrag(event, type, item) {
if (!item.available && item.available !== undefined) {
event.preventDefault();
return;
}
this.draggingItem = { type, ...item };
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', JSON.stringify({ type, id: item.id }));
......@@ -433,6 +582,29 @@ class="text-[10px] text-red-500 hover:text-red-700 hover:underline">{{ __('إل
this.selectedSegments.push(segId);
}
},
selectRow() {
if (this.selectedSegments.length === 0) return;
const segments = @json($gridSegments);
const firstSelected = segments.find(s => s.id === this.selectedSegments[0]);
if (!firstSelected) return;
const rowSegs = segments.filter(s => s.row === firstSelected.row && s.available).map(s => s.id);
this.selectedSegments = [...new Set([...this.selectedSegments, ...rowSegs])];
},
selectCol() {
if (this.selectedSegments.length === 0) return;
const segments = @json($gridSegments);
const firstSelected = segments.find(s => s.id === this.selectedSegments[0]);
if (!firstSelected) return;
const colSegs = segments.filter(s => s.col === firstSelected.col && s.available).map(s => s.id);
this.selectedSegments = [...new Set([...this.selectedSegments, ...colSegs])];
},
selectAll() {
const segments = @json($gridSegments);
this.selectedSegments = segments.filter(s => s.available).map(s => s.id);
},
}));
</script>
@endscript
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