@@ -22,7 +22,8 @@ class Reservation extends Model
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'reference', 'requester_id', 'purpose', 'usage_location', 'starts_at',
|
||||
'uuid', 'reference', 'requester_id', 'recurring_group_id', 'recurrence_pattern',
|
||||
'purpose', 'usage_location', 'starts_at',
|
||||
'ends_at', 'status', 'approval_deadline', 'approved_by', 'approved_at',
|
||||
'rejected_by', 'rejected_at', 'decision_reason', 'cancelled_by',
|
||||
'cancelled_at', 'cancellation_reason',
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\User;
|
||||
use App\Notifications\ReservationSubmittedNotification;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -258,6 +259,396 @@ class ReservationService
|
||||
return $expired;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $assetIds
|
||||
* @return array{
|
||||
* occurrences: list<array{
|
||||
* index: int,
|
||||
* date_formatted: string,
|
||||
* time_formatted: string,
|
||||
* starts_at: string,
|
||||
* ends_at: string,
|
||||
* has_conflict: bool,
|
||||
* conflicted_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null, alternatives: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>}>,
|
||||
* available_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>,
|
||||
* }>,
|
||||
* total_occurrences: int,
|
||||
* conflicts_count: int,
|
||||
* }
|
||||
*/
|
||||
public function checkRecurringSeries(
|
||||
array $assetIds,
|
||||
CarbonImmutable $startsAt,
|
||||
CarbonImmutable $endsAt,
|
||||
CarbonImmutable $repeatUntil,
|
||||
): array {
|
||||
$assets = SnipeAsset::query()
|
||||
->whereKey($assetIds)
|
||||
->get();
|
||||
|
||||
$occurrences = [];
|
||||
$curStartsAt = $startsAt;
|
||||
$curEndsAt = $endsAt;
|
||||
$durationMinutes = $startsAt->diffInMinutes($endsAt);
|
||||
$conflictsTotal = 0;
|
||||
$index = 0;
|
||||
|
||||
while ($curStartsAt->lte($repeatUntil) && $index < 26) {
|
||||
$conflictedAssets = [];
|
||||
$availableAssets = [];
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
if ($this->availability->isAvailable($asset, $curStartsAt, $curEndsAt)) {
|
||||
$availableAssets[] = [
|
||||
'id' => $asset->id,
|
||||
'name' => $asset->name,
|
||||
'asset_tag' => $asset->asset_tag,
|
||||
'model_name' => $asset->model_name,
|
||||
'category_name' => $asset->category_name,
|
||||
];
|
||||
} else {
|
||||
$alternativesQuery = $this->availability->availableAssets($curStartsAt, $curEndsAt)
|
||||
->whereNotIn('id', $assetIds);
|
||||
|
||||
if ($asset->category_name !== null) {
|
||||
$alternativesQuery->where('category_name', $asset->category_name);
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||
$alternatives = $alternativesQuery
|
||||
->limit(8)
|
||||
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||
->map(fn (SnipeAsset $alt): array => [
|
||||
'id' => $alt->id,
|
||||
'name' => $alt->name,
|
||||
'asset_tag' => $alt->asset_tag,
|
||||
'model_name' => $alt->model_name,
|
||||
'category_name' => $alt->category_name,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($alternatives)) {
|
||||
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||
$alternatives = $this->availability->availableAssets($curStartsAt, $curEndsAt)
|
||||
->whereNotIn('id', $assetIds)
|
||||
->limit(6)
|
||||
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||
->map(fn (SnipeAsset $alt): array => [
|
||||
'id' => $alt->id,
|
||||
'name' => $alt->name,
|
||||
'asset_tag' => $alt->asset_tag,
|
||||
'model_name' => $alt->model_name,
|
||||
'category_name' => $alt->category_name,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
$conflictedAssets[] = [
|
||||
'id' => $asset->id,
|
||||
'name' => $asset->name,
|
||||
'asset_tag' => $asset->asset_tag,
|
||||
'model_name' => $asset->model_name,
|
||||
'category_name' => $asset->category_name,
|
||||
'alternatives' => $alternatives,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$hasConflict = count($conflictedAssets) > 0;
|
||||
if ($hasConflict) {
|
||||
$conflictsTotal++;
|
||||
}
|
||||
|
||||
$dayOfWeekPt = match ($curStartsAt->dayOfWeek) {
|
||||
0 => 'Domingo',
|
||||
1 => 'Segunda-feira',
|
||||
2 => 'Terça-feira',
|
||||
3 => 'Quarta-feira',
|
||||
4 => 'Quinta-feira',
|
||||
5 => 'Sexta-feira',
|
||||
6 => 'Sábado',
|
||||
default => 'Dia',
|
||||
};
|
||||
|
||||
$occurrences[] = [
|
||||
'index' => $index,
|
||||
'date_formatted' => sprintf('%s, %s', $dayOfWeekPt, $curStartsAt->format('d/m/Y')),
|
||||
'time_formatted' => sprintf('%s às %s', $curStartsAt->format('H:i'), $curEndsAt->format('H:i')),
|
||||
'starts_at' => $curStartsAt->toIso8601String(),
|
||||
'ends_at' => $curEndsAt->toIso8601String(),
|
||||
'has_conflict' => $hasConflict,
|
||||
'conflicted_assets' => $conflictedAssets,
|
||||
'available_assets' => $availableAssets,
|
||||
];
|
||||
|
||||
$curStartsAt = $curStartsAt->addWeek();
|
||||
$curEndsAt = $curStartsAt->addMinutes($durationMinutes);
|
||||
$index++;
|
||||
}
|
||||
|
||||
return [
|
||||
'occurrences' => $occurrences,
|
||||
'total_occurrences' => count($occurrences),
|
||||
'conflicts_count' => $conflictsTotal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $assetIds
|
||||
* @param list<array{day_of_week: int, start_time: string, end_time: string}> $weeklySlots
|
||||
* @return array{
|
||||
* occurrences: list<array{
|
||||
* index: int,
|
||||
* date_formatted: string,
|
||||
* time_formatted: string,
|
||||
* starts_at: string,
|
||||
* ends_at: string,
|
||||
* has_conflict: bool,
|
||||
* conflicted_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null, alternatives: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>}>,
|
||||
* available_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>,
|
||||
* }>,
|
||||
* total_occurrences: int,
|
||||
* conflicts_count: int,
|
||||
* }
|
||||
*/
|
||||
public function checkFlexibleRecurringSeries(
|
||||
array $assetIds,
|
||||
array $weeklySlots,
|
||||
CarbonImmutable $repeatUntil,
|
||||
): array {
|
||||
$assets = SnipeAsset::query()
|
||||
->whereKey($assetIds)
|
||||
->get();
|
||||
|
||||
$occurrences = [];
|
||||
$conflictsTotal = 0;
|
||||
$index = 0;
|
||||
$currentDate = now()->startOfDay();
|
||||
|
||||
$dayNames = [
|
||||
1 => 'Segunda-feira',
|
||||
2 => 'Terça-feira',
|
||||
3 => 'Quarta-feira',
|
||||
4 => 'Quinta-feira',
|
||||
5 => 'Sexta-feira',
|
||||
];
|
||||
|
||||
while ($currentDate->lte($repeatUntil) && $index < 26) {
|
||||
foreach ($weeklySlots as $slot) {
|
||||
if ($currentDate->dayOfWeek !== $slot['day_of_week']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startsAt = $currentDate->setTimeFromTimeString($slot['start_time']);
|
||||
$endsAt = $currentDate->setTimeFromTimeString($slot['end_time']);
|
||||
|
||||
if ($startsAt->lt(now()) || $startsAt->gt($repeatUntil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conflictedAssets = [];
|
||||
$availableAssets = [];
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
if ($this->availability->isAvailable($asset, $startsAt, $endsAt)) {
|
||||
$availableAssets[] = [
|
||||
'id' => $asset->id,
|
||||
'name' => $asset->name,
|
||||
'asset_tag' => $asset->asset_tag,
|
||||
'model_name' => $asset->model_name,
|
||||
'category_name' => $asset->category_name,
|
||||
];
|
||||
} else {
|
||||
$alternativesQuery = $this->availability->availableAssets($startsAt, $endsAt)
|
||||
->whereNotIn('id', $assetIds);
|
||||
|
||||
if ($asset->category_name !== null) {
|
||||
$alternativesQuery->where('category_name', $asset->category_name);
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||
$alternatives = $alternativesQuery
|
||||
->limit(8)
|
||||
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||
->map(fn (SnipeAsset $alt): array => [
|
||||
'id' => $alt->id,
|
||||
'name' => $alt->name,
|
||||
'asset_tag' => $alt->asset_tag,
|
||||
'model_name' => $alt->model_name,
|
||||
'category_name' => $alt->category_name,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($alternatives)) {
|
||||
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||
$alternatives = $this->availability->availableAssets($startsAt, $endsAt)
|
||||
->whereNotIn('id', $assetIds)
|
||||
->limit(6)
|
||||
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||
->map(fn (SnipeAsset $alt): array => [
|
||||
'id' => $alt->id,
|
||||
'name' => $alt->name,
|
||||
'asset_tag' => $alt->asset_tag,
|
||||
'model_name' => $alt->model_name,
|
||||
'category_name' => $alt->category_name,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
$conflictedAssets[] = [
|
||||
'id' => $asset->id,
|
||||
'name' => $asset->name,
|
||||
'asset_tag' => $asset->asset_tag,
|
||||
'model_name' => $asset->model_name,
|
||||
'category_name' => $asset->category_name,
|
||||
'alternatives' => $alternatives,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$hasConflict = count($conflictedAssets) > 0;
|
||||
if ($hasConflict) {
|
||||
$conflictsTotal++;
|
||||
}
|
||||
|
||||
$dayOfWeekPt = $dayNames[$slot['day_of_week']] ?? 'Dia';
|
||||
|
||||
$occurrences[] = [
|
||||
'index' => $index,
|
||||
'date_formatted' => sprintf('%s, %s', $dayOfWeekPt, $startsAt->format('d/m/Y')),
|
||||
'time_formatted' => sprintf('%s às %s', $startsAt->format('H:i'), $endsAt->format('H:i')),
|
||||
'starts_at' => $startsAt->toIso8601String(),
|
||||
'ends_at' => $endsAt->toIso8601String(),
|
||||
'has_conflict' => $hasConflict,
|
||||
'conflicted_assets' => $conflictedAssets,
|
||||
'available_assets' => $availableAssets,
|
||||
];
|
||||
|
||||
$index++;
|
||||
}
|
||||
|
||||
$currentDate = $currentDate->addDay();
|
||||
}
|
||||
|
||||
return [
|
||||
'occurrences' => $occurrences,
|
||||
'total_occurrences' => count($occurrences),
|
||||
'conflicts_count' => $conflictsTotal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{purpose: string, usage_location: string} $baseData
|
||||
* @param list<array{starts_at: string, ends_at: string, asset_ids: list<int>}> $occurrences
|
||||
* @return Collection<int, Reservation>
|
||||
*/
|
||||
public function createRecurringPending(User $requester, array $baseData, array $occurrences): Collection
|
||||
{
|
||||
if (empty($occurrences)) {
|
||||
throw ValidationException::withMessages(['occurrences' => 'Deve selecionar pelo menos um dia para a requisição recorrente.']);
|
||||
}
|
||||
|
||||
if (count($occurrences) > 26) {
|
||||
throw ValidationException::withMessages(['occurrences' => 'A série recorrente não pode exceder 26 semanas.']);
|
||||
}
|
||||
|
||||
foreach ($occurrences as $occ) {
|
||||
$startsAt = CarbonImmutable::parse($occ['starts_at']);
|
||||
$endsAt = CarbonImmutable::parse($occ['ends_at']);
|
||||
$this->validatePolicy($startsAt, $endsAt, count($occ['asset_ids']));
|
||||
}
|
||||
|
||||
$recurringGroupId = (string) Str::uuid();
|
||||
|
||||
/** @var Collection<int, Reservation> $reservations */
|
||||
$reservations = DB::transaction(function () use ($requester, $baseData, $occurrences, $recurringGroupId): Collection {
|
||||
$created = collect();
|
||||
|
||||
foreach ($occurrences as $occ) {
|
||||
$startsAt = CarbonImmutable::parse($occ['starts_at']);
|
||||
$endsAt = CarbonImmutable::parse($occ['ends_at']);
|
||||
[$bufferStartsAt, $bufferEndsAt] = $this->availability->bufferedPeriod($startsAt, $endsAt);
|
||||
|
||||
$assets = SnipeAsset::query()
|
||||
->whereKey($occ['asset_ids'])
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
if ($assets->count() !== count(array_unique($occ['asset_ids']))) {
|
||||
throw ValidationException::withMessages(['occurrences' => "Um ou mais equipamentos na data {$startsAt->format('d/m/Y')} já não estão disponíveis."]);
|
||||
}
|
||||
|
||||
$unavailable = $assets->filter(
|
||||
fn (SnipeAsset $asset): bool => ! $this->availability->isAvailable($asset, $startsAt, $endsAt)
|
||||
);
|
||||
|
||||
if ($unavailable->isNotEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'occurrences' => "Conflito na data {$startsAt->format('d/m/Y')}: {$unavailable->pluck('name')->join(', ')}.",
|
||||
]);
|
||||
}
|
||||
|
||||
$reservation = Reservation::create([
|
||||
'reference' => $this->nextReference(),
|
||||
'requester_id' => $requester->id,
|
||||
'recurring_group_id' => $recurringGroupId,
|
||||
'recurrence_pattern' => 'weekly',
|
||||
'purpose' => $baseData['purpose'],
|
||||
'usage_location' => $baseData['usage_location'],
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'status' => ReservationStatus::PendingApproval,
|
||||
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
|
||||
]);
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$reservation->items()->create([
|
||||
'snipe_asset_id' => $asset->id,
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'buffer_starts_at' => $bufferStartsAt,
|
||||
'buffer_ends_at' => $bufferEndsAt,
|
||||
'status' => ReservationItemStatus::Held,
|
||||
]);
|
||||
}
|
||||
|
||||
AuditLog::create([
|
||||
'actor_id' => $requester->id,
|
||||
'event' => 'reservation.recurring_submitted',
|
||||
'auditable_type' => $reservation->getMorphClass(),
|
||||
'auditable_id' => $reservation->id,
|
||||
'after' => $reservation->only(['reference', 'purpose', 'usage_location', 'starts_at', 'ends_at', 'status', 'recurring_group_id', 'recurrence_pattern']),
|
||||
'metadata' => [
|
||||
'asset_ids' => $assets->modelKeys(),
|
||||
'recurring_group_id' => $recurringGroupId,
|
||||
],
|
||||
]);
|
||||
|
||||
$created->push($reservation->load('requester', 'items.asset'));
|
||||
}
|
||||
|
||||
return $created;
|
||||
}, 3);
|
||||
|
||||
$approvers = User::query()
|
||||
->where('active', true)
|
||||
->whereHas('roles', fn ($query) => $query->whereIn('name', [
|
||||
RoleName::Approver->value,
|
||||
RoleName::Administrator->value,
|
||||
]))
|
||||
->get();
|
||||
|
||||
foreach ($reservations as $reservation) {
|
||||
Notification::send($approvers, new ReservationSubmittedNotification($reservation));
|
||||
}
|
||||
|
||||
return $reservations;
|
||||
}
|
||||
|
||||
private function nextReference(): string
|
||||
{
|
||||
do {
|
||||
|
||||
@@ -12,20 +12,33 @@ class GlobalCalendarController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$filters = $request->validate(['month' => ['nullable', 'date_format:Y-m']]);
|
||||
$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();
|
||||
|
||||
$query = Reservation::query()
|
||||
->with(['requester:id,name,department', 'items.asset:id,name,asset_tag'])
|
||||
->where('starts_at', '<', $month->addMonth())
|
||||
->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', [
|
||||
'scope' => 'global',
|
||||
'month' => $month->format('Y-m'),
|
||||
'reservations' => Reservation::query()
|
||||
->with(['requester:id,name,department', 'items.asset:id,name,asset_tag'])
|
||||
->where('starts_at', '<', $month->addMonth())
|
||||
->where('ends_at', '>', $month)
|
||||
->orderBy('starts_at')
|
||||
->get(),
|
||||
'reservations' => $query->orderBy('starts_at')->get(),
|
||||
'status' => $filters['status'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@ 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;
|
||||
|
||||
@@ -18,11 +22,20 @@ 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' => $request->user()->reservations()
|
||||
->with('items.asset:id,name,asset_tag')
|
||||
->latest()
|
||||
->paginate(15),
|
||||
'reservations' => $query->latest()->paginate(15),
|
||||
'status' => $filters['status'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -30,20 +43,31 @@ class ReservationController extends Controller
|
||||
{
|
||||
$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' => $request->user()->reservations()
|
||||
->with('items.asset:id,name,asset_tag')
|
||||
->where('starts_at', '<', $windowEnd)
|
||||
->where('ends_at', '>', $month)
|
||||
->orderBy('starts_at')
|
||||
->get(),
|
||||
'reservations' => $query->orderBy('starts_at')->get(),
|
||||
'status' => $filters['status'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -92,6 +116,225 @@ class ReservationController extends Controller
|
||||
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([
|
||||
|
||||
@@ -43,7 +43,12 @@ class HandleInertiaRequests extends Middleware
|
||||
],
|
||||
'flash' => [
|
||||
'success' => fn () => $request->session()->get('success'),
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
// Não partilhar flash.error quando há erros de validação na sessão:
|
||||
// os erros de campo são tratados pelo Inertia diretamente via form.errors
|
||||
// e devem aparecer abaixo dos campos, não como toast global.
|
||||
'error' => fn () => $request->session()->has('errors')
|
||||
? null
|
||||
: $request->session()->get('error'),
|
||||
],
|
||||
'can' => [
|
||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
||||
|
||||
28
app/Http/Requests/StoreRecurringReservationRequest.php
Normal file
28
app/Http/Requests/StoreRecurringReservationRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreRecurringReservationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->active === true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'usage_location' => ['required', 'string', 'max:255'],
|
||||
'terms_accepted' => ['accepted'],
|
||||
'occurrences' => ['required', 'array', 'min:1', 'max:26'],
|
||||
'occurrences.*.starts_at' => ['required', 'date', 'after:now'],
|
||||
'occurrences.*.ends_at' => ['required', 'date', 'after:occurrences.*.starts_at'],
|
||||
'occurrences.*.asset_ids' => ['required', 'array', 'min:1'],
|
||||
'occurrences.*.asset_ids.*' => ['required', 'integer', 'exists:snipe_assets,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ class StoreReservationRequest extends FormRequest
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
$now = now();
|
||||
$nowFormatted = $now->format('d/m/Y \à\s H:i');
|
||||
|
||||
if ($validator->errors()->has('starts_at')) {
|
||||
$validator->errors()->add('starts_at', "A data de início deve ser posterior às {$nowFormatted}.");
|
||||
}
|
||||
|
||||
if ($validator->errors()->hasAny(['starts_at', 'ends_at'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user