359 lines
16 KiB
PHP
359 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Domain\Administration\Services\ReservationPolicy;
|
|
use App\Domain\Inventory\Models\SnipeAsset;
|
|
use App\Domain\Reservations\Models\Reservation;
|
|
use App\Domain\Reservations\Services\AvailabilityService;
|
|
use App\Domain\Reservations\Services\ReservationService;
|
|
use App\Http\Requests\StoreRecurringReservationRequest;
|
|
use App\Http\Requests\StoreReservationRequest;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class ReservationController extends Controller
|
|
{
|
|
public function index(Request $request): Response
|
|
{
|
|
$filters = $request->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<int>} $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<array{day_of_week: int, start_time: string, end_time: string}>, repeat_until: string|null, weeks_count: int|null, asset_ids: list<int>} $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<array{starts_at: string, ends_at: string, asset_ids: list<int>}> $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}.");
|
|
}
|
|
}
|