validate([ 'status' => ['nullable', 'string'], ]); $query = $request->user()->reservations() ->with('items.asset:id,name,asset_tag'); if (! empty($filters['status'])) { $query->where('status', $filters['status']); } return Inertia::render('reservations/index', [ 'reservations' => $query->latest()->paginate(15), 'status' => $filters['status'] ?? '', ]); } public function calendar(Request $request): Response { $filters = $request->validate([ 'month' => ['nullable', 'date_format:Y-m'], 'status' => ['nullable', 'string', 'in:pending,confirmed,closed'], ]); $month = isset($filters['month']) ? CarbonImmutable::createFromFormat('!Y-m', $filters['month']) : now()->startOfMonth(); $windowEnd = $month->addMonth(); $query = $request->user()->reservations() ->with('items.asset:id,name,asset_tag') ->where('starts_at', '<', $windowEnd) ->where('ends_at', '>', $month); if (! empty($filters['status'])) { $statusMap = [ 'pending' => ['pending_approval', 'awaiting_information'], 'confirmed' => ['approved', 'ready', 'delivered', 'in_use'], 'closed' => ['rejected', 'cancelled', 'expired', 'no_show', 'completed'], ]; $query->whereIn('status', $statusMap[$filters['status']]); } return Inertia::render('reservations/calendar', [ 'month' => $month->format('Y-m'), 'reservations' => $query->orderBy('starts_at')->get(), 'status' => $filters['status'] ?? '', ]); } public function create(Request $request, AvailabilityService $availability, ReservationPolicy $policy): Response { $period = $request->validate([ 'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'], 'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'], ]); $periodSelected = isset($period['starts_at'], $period['ends_at']); $query = $periodSelected ? $availability->availableAssets( CarbonImmutable::parse($period['starts_at']), CarbonImmutable::parse($period['ends_at']), ) : SnipeAsset::query() ->where('active', true) ->where('requestable', true) ->whereNull('assigned_id'); $assets = $query ->orderBy('name') ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']); $requestedAssetId = $request->integer('asset'); return Inertia::render('reservations/create', [ 'assets' => $assets, 'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0, 'period' => $period, 'periodSelected' => $periodSelected, 'policy' => [ 'minNoticeHours' => $policy->integer('reservation.min_notice_hours'), 'maxDurationHours' => $policy->integer('reservation.max_duration_hours'), 'turnaroundMinutes' => $policy->integer('reservation.turnaround_minutes'), 'maxAssets' => $policy->integer('reservation.max_assets'), ], ]); } public function store(StoreReservationRequest $request, ReservationService $service): RedirectResponse { /** @var array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list} $data */ $data = $request->safe()->only(['purpose', 'usage_location', 'starts_at', 'ends_at', 'asset_ids']); $reservation = $service->createPending($request->user(), $data); return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação."); } public function checkRecurrence(Request $request, ReservationService $service): JsonResponse { $data = $request->validate([ 'weekly_slots' => ['required', 'array', 'min:1', 'max:5'], 'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'], 'weekly_slots.*.start_time' => ['required', 'date_format:H:i'], 'weekly_slots.*.end_time' => ['required', 'date_format:H:i'], 'repeat_until' => ['nullable', 'date', 'after:now'], 'weeks_count' => ['nullable', 'integer', 'min:1', 'max:26'], 'asset_ids' => ['required', 'array', 'min:1'], 'asset_ids.*' => ['required', 'integer', 'exists:snipe_assets,id'], ]); // Custom validation: end_time must be after start_time for each slot foreach ($data['weekly_slots'] as $index => $slot) { if ($slot['start_time'] >= $slot['end_time']) { throw ValidationException::withMessages([ "weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.', ]); } } /** @var array{weekly_slots: list, repeat_until: string|null, weeks_count: int|null, asset_ids: list} $data */ $repeatUntil = ! empty($data['repeat_until']) ? CarbonImmutable::parse($data['repeat_until'])->setTime(23, 59, 59) : now()->addWeeks(($data['weeks_count'] ?? 4) - 1)->endOfDay(); $result = $service->checkFlexibleRecurringSeries( $data['asset_ids'], $data['weekly_slots'], $repeatUntil, ); return response()->json($result); } public function checkWeeklyAvailability(Request $request, AvailabilityService $availability): JsonResponse { $data = $request->validate([ 'weekly_slots' => ['required', 'array', 'min:1', 'max:5'], 'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'], 'weekly_slots.*.start_time' => ['required', 'date_format:H:i'], 'weekly_slots.*.end_time' => ['required', 'date_format:H:i'], 'week_offset' => ['nullable', 'integer', 'min:0'], ]); // Custom validation: end_time must be after start_time for each slot foreach ($data['weekly_slots'] as $index => $slot) { if ($slot['start_time'] >= $slot['end_time']) { throw ValidationException::withMessages([ "weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.', ]); } } $weekOffset = $data['week_offset'] ?? 0; $now = now(); $startOfWeek = $now->copy()->addWeeks($weekOffset)->startOfWeek(CarbonImmutable::MONDAY); // Calculate specific dates for each slot $slotPeriods = []; foreach ($data['weekly_slots'] as $slot) { $slotDate = $startOfWeek->copy()->addDays($slot['day_of_week'] - 1); $startsAt = $slotDate->setTimeFromTimeString($slot['start_time']); $endsAt = $slotDate->setTimeFromTimeString($slot['end_time']); $slotPeriods[] = ['starts_at' => $startsAt, 'ends_at' => $endsAt]; } // Find assets available for ALL slots (intersection) $availableAssets = null; foreach ($slotPeriods as $period) { $assetsForSlot = $availability->availableAssets($period['starts_at'], $period['ends_at']) ->orderBy('name') ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']) ->keyBy('id'); if ($availableAssets === null) { $availableAssets = $assetsForSlot; } else { // Intersect: keep only assets available in both sets $availableAssets = $availableAssets->intersectByKeys($assetsForSlot); } } return response()->json([ 'assets' => $availableAssets ? $availableAssets->values() : collect(), ]); } public function checkSlotAvailability(Request $request, AvailabilityService $availability): JsonResponse { $data = $request->validate([ 'weekly_slots' => ['required', 'array'], 'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'], 'weekly_slots.*.start_time' => ['required', 'date_format:H:i'], 'weekly_slots.*.end_time' => ['required', 'date_format:H:i'], 'week_offset' => ['nullable', 'integer', 'min:0'], 'asset_ids' => ['nullable', 'array'], 'asset_ids.*' => ['nullable', 'integer', 'exists:snipe_assets,id'], ]); // Custom validation: end_time must be after start_time for each slot foreach ($data['weekly_slots'] as $index => $slot) { if ($slot['start_time'] >= $slot['end_time']) { throw ValidationException::withMessages([ "weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.', ]); } } $now = now(); $weekOffset = $data['week_offset'] ?? 0; $startOfWeek = $now->copy()->addWeeks($weekOffset)->startOfWeek(CarbonImmutable::MONDAY); $availabilityMap = []; foreach ($data['weekly_slots'] as $slot) { // Calculate the date based on week offset $slotDate = $startOfWeek->copy()->addDays($slot['day_of_week'] - 1); $startsAt = $slotDate->setTimeFromTimeString($slot['start_time']); $endsAt = $slotDate->setTimeFromTimeString($slot['end_time']); $key = "{$slot['day_of_week']}-{$slot['start_time']}-{$slot['end_time']}"; // Check availability for this specific slot $availableAssets = $availability->availableAssets($startsAt, $endsAt); // If specific asset_ids are provided, check if any of them are available if (! empty($data['asset_ids'])) { $availableIds = $availableAssets->pluck('id')->toArray(); $hasAvailable = false; foreach ($data['asset_ids'] as $assetId) { if (in_array($assetId, $availableIds)) { $hasAvailable = true; break; } } $availabilityMap[$key] = $hasAvailable; } else { // If no specific assets, check if any asset is available $availabilityMap[$key] = $availableAssets->count() > 0; } } return response()->json([ 'availability' => $availabilityMap, ]); } public function storeRecurring(StoreRecurringReservationRequest $request, ReservationService $service): RedirectResponse { /** @var array{purpose: string, usage_location: string} $baseData */ $baseData = $request->safe()->only(['purpose', 'usage_location']); /** @var list}> $occurrences */ $occurrences = $request->validated('occurrences'); $reservations = $service->createRecurringPending($request->user(), $baseData, $occurrences); $count = $reservations->count(); return to_route('dashboard')->with('success', "Série recorrente submetida com {$count} requisições para aprovação."); } public function storeMultiple(Request $request, ReservationService $service): RedirectResponse { $data = $request->validate([ 'weekly_slots' => ['required', 'array', 'min:1'], 'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'], 'weekly_slots.*.start_time' => ['required', 'date_format:H:i'], 'weekly_slots.*.end_time' => ['required', 'date_format:H:i'], 'week_offset' => ['nullable', 'integer', 'min:0'], 'purpose' => ['required', 'string', 'min:10', 'max:2000'], 'usage_location' => ['required', 'string', 'max:255'], 'asset_ids' => ['required', 'array', 'min:1'], 'asset_ids.*' => ['required', 'integer', 'distinct', 'exists:snipe_assets,id'], 'terms_accepted' => ['accepted'], ]); // Custom validation: end_time must be after start_time for each slot foreach ($data['weekly_slots'] as $index => $slot) { if ($slot['start_time'] >= $slot['end_time']) { throw ValidationException::withMessages([ "weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.', ]); } } $now = now(); $weekOffset = $data['week_offset'] ?? 0; $startOfWeek = $now->copy()->addWeeks($weekOffset)->startOfWeek(CarbonImmutable::MONDAY); $baseData = [ 'purpose' => $data['purpose'], 'usage_location' => $data['usage_location'], 'asset_ids' => $data['asset_ids'], ]; $reservations = collect(); DB::transaction(function () use ($now, $weekOffset, $startOfWeek, $data, $baseData, $service, $request, &$reservations) { foreach ($data['weekly_slots'] as $slot) { // Calculate the date based on week offset $slotDate = $startOfWeek->copy()->addDays($slot['day_of_week'] - 1); $startsAt = $slotDate->setTimeFromTimeString($slot['start_time']); $endsAt = $slotDate->setTimeFromTimeString($slot['end_time']); $slotData = array_merge($baseData, [ 'starts_at' => $startsAt->toIso8601String(), 'ends_at' => $endsAt->toIso8601String(), ]); $reservation = $service->createPending($request->user(), $slotData); $reservations->push($reservation); } }); $count = $reservations->count(); $references = $reservations->pluck('reference')->join(', '); return to_route('dashboard')->with('success', "Criadas {$count} requisições ({$references}) para aprovação."); } public function cancel(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse { $data = $request->validate([ 'reason' => ['required', 'string', 'min:5', 'max:1000'], ]); $service->cancel($reservation, $request->user(), $data['reason']); return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis."); } public function respondToInformation(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse { $data = $request->validate([ 'response' => ['required', 'string', 'min:5', 'max:2000'], ]); $service->respondToInformation($reservation, $request->user(), $data['response']); return back()->with('success', "Esclarecimentos enviados para a requisição {$reservation->reference}."); } }