From fb04d71a8d71add5ade7fc6e6d4c99fa4a38089d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Miranda?= Date: Tue, 27 Oct 2026 11:01:11 +0000 Subject: [PATCH] first commit --- .env.example | 1 + CHANGELOG.md | 11 + .../Reservations/Models/Reservation.php | 3 +- .../Services/ReservationService.php | 391 ++ .../Controllers/GlobalCalendarController.php | 27 +- .../Controllers/ReservationController.php | 263 +- app/Http/Middleware/HandleInertiaRequests.php | 7 +- .../StoreRecurringReservationRequest.php | 28 + app/Http/Requests/StoreReservationRequest.php | 7 + config/app.php | 2 +- config/inertia.php | 4 +- ...ecurring_columns_to_reservations_table.php | 24 + docs/PROJECT_STATUS.md | 57 +- lang/pt/validation.php | 170 + lang/pt_PT/validation.php | 170 + .../components/recurring-conflict-modal.tsx | 690 +++ .../js/components/weekly-slot-selector.tsx | 216 + resources/js/pages/assets/index.tsx | 326 +- resources/js/pages/auth/login.tsx | 43 +- resources/js/pages/dashboard.tsx | 40 +- resources/js/pages/reservations/calendar.tsx | 198 +- resources/js/pages/reservations/create.tsx | 1675 ++++- resources/js/pages/reservations/index.tsx | 90 +- resources/js/types/portal.ts | 28 + routes/web.php | 5 + swagger.json | 5406 +++++++++++++++++ tests/Feature/AdminReservationPolicyTest.php | 2 +- tests/Feature/AvailabilityServiceTest.php | 2 + tests/Feature/RecurringReservationTest.php | 173 + 29 files changed, 9733 insertions(+), 326 deletions(-) create mode 100644 app/Http/Requests/StoreRecurringReservationRequest.php create mode 100644 database/migrations/2026_10_09_000100_add_recurring_columns_to_reservations_table.php create mode 100644 lang/pt/validation.php create mode 100644 lang/pt_PT/validation.php create mode 100644 resources/js/components/recurring-conflict-modal.tsx create mode 100644 resources/js/components/weekly-slot-selector.tsx create mode 100644 swagger.json create mode 100644 tests/Feature/RecurringReservationTest.php diff --git a/.env.example b/.env.example index c083c4a..0cd9434 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ APP_DEBUG=true APP_URL=http://localhost:8000 APP_PORT=8000 VITE_PORT=5173 +INERTIA_SSR_ENABLED=false INERTIA_SSR_HOT_URL=http://node:5173 APP_LOCALE=pt_PT diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a3c25..4702481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,17 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Endpoints públicos de liveness/readiness sem divulgação de detalhes internos e health check do Nginx. - Scripts operacionais para backup PostgreSQL custom, checksum e teste de restauro numa base temporária isolada. - Checklist versionado de entrada em produção com evidências e critérios de go/no-go. +- Endpoint `/requisicoes/verificar-disponibilidade-semanal` para verificar disponibilidade de equipamentos baseado em slots semanais para reservas recorrentes. +- Botão "Verificar equipamentos livres" visível quando a repetição semanal está ativa, permitindo filtrar o catálogo pela próxima ocorrência semanal. + +### Alterado + +- Validação de formulário de requisição: erros de validação agora aparecem individualmente por baixo de cada campo em vez de num toast/alert genérico. +- Removido listener global de erros do Inertia que interferia com o fluxo normal de validação. +- Adicionado `preserveState: true` e `preserveScroll: true` aos POSTs do formulário para manter o estado após erros de validação. +- Modal de detalhes do dia nos calendários pessoal e global, mostrando todas as requisições de um dia com estado, horário, equipamentos e requisitante (no calendário global). +- Fuso horário da aplicação alterado de UTC para Europe/Lisbon para garantir consistência com o PHP e evitar discrepâncias de horário entre frontend e backend. +- Botão "Verificar equipamentos livres" no formulário de requisição quando a repetição semanal está ativa, permitindo filtrar o catálogo pela próxima ocorrência semanal. ### Alterado diff --git a/app/Domain/Reservations/Models/Reservation.php b/app/Domain/Reservations/Models/Reservation.php index 132e04c..891c044 100644 --- a/app/Domain/Reservations/Models/Reservation.php +++ b/app/Domain/Reservations/Models/Reservation.php @@ -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', diff --git a/app/Domain/Reservations/Services/ReservationService.php b/app/Domain/Reservations/Services/ReservationService.php index 2755bad..27f5343 100644 --- a/app/Domain/Reservations/Services/ReservationService.php +++ b/app/Domain/Reservations/Services/ReservationService.php @@ -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 $assetIds + * @return array{ + * occurrences: list}>, + * available_assets: list, + * }>, + * 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 $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 $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 $assetIds + * @param list $weeklySlots + * @return array{ + * occurrences: list}>, + * available_assets: list, + * }>, + * 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 $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 $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}> $occurrences + * @return Collection + */ + 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 $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 { diff --git a/app/Http/Controllers/GlobalCalendarController.php b/app/Http/Controllers/GlobalCalendarController.php index 885258f..50af4ec 100644 --- a/app/Http/Controllers/GlobalCalendarController.php +++ b/app/Http/Controllers/GlobalCalendarController.php @@ -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'] ?? '', ]); } } diff --git a/app/Http/Controllers/ReservationController.php b/app/Http/Controllers/ReservationController.php index 50977c8..84287fd 100644 --- a/app/Http/Controllers/ReservationController.php +++ b/app/Http/Controllers/ReservationController.php @@ -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, 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([ diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2e7b895..2792435 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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, diff --git a/app/Http/Requests/StoreRecurringReservationRequest.php b/app/Http/Requests/StoreRecurringReservationRequest.php new file mode 100644 index 0000000..df8685f --- /dev/null +++ b/app/Http/Requests/StoreRecurringReservationRequest.php @@ -0,0 +1,28 @@ +user()?->active === true; + } + + /** @return array> */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/StoreReservationRequest.php b/app/Http/Requests/StoreReservationRequest.php index 5f28f6a..88047b4 100644 --- a/app/Http/Requests/StoreReservationRequest.php +++ b/app/Http/Requests/StoreReservationRequest.php @@ -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; } diff --git a/config/app.php b/config/app.php index 1f8dd27..fc9b4e9 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => 'Europe/Lisbon', /* |-------------------------------------------------------------------------- diff --git a/config/inertia.php b/config/inertia.php index 537674e..ab6e7c1 100644 --- a/config/inertia.php +++ b/config/inertia.php @@ -16,8 +16,8 @@ return [ */ 'ssr' => [ - 'enabled' => true, - 'url' => 'http://127.0.0.1:13714', + 'enabled' => env('INERTIA_SSR_ENABLED', false), + 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), 'hot_url' => env('INERTIA_SSR_HOT_URL'), // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), diff --git a/database/migrations/2026_10_09_000100_add_recurring_columns_to_reservations_table.php b/database/migrations/2026_10_09_000100_add_recurring_columns_to_reservations_table.php new file mode 100644 index 0000000..77dc501 --- /dev/null +++ b/database/migrations/2026_10_09_000100_add_recurring_columns_to_reservations_table.php @@ -0,0 +1,24 @@ +uuid('recurring_group_id')->nullable()->index()->after('requester_id'); + $table->string('recurrence_pattern')->nullable()->after('recurring_group_id'); + }); + } + + public function down(): void + { + Schema::table('reservations', function (Blueprint $table) { + $table->dropIndex(['recurring_group_id']); + $table->dropColumn(['recurring_group_id', 'recurrence_pattern']); + }); + } +}; diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 01cae64..b34c920 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,8 +1,8 @@ # Estado do projeto e handoff -**Atualizado em:** 5 de agosto de 2026 +**Atualizado em:** 15 de outubro de 2026 **Fase:** Fases 1 a 6 concluídas; Fase 7 em curso (identidade, saúde, recuperação e ligação de leitura ao Snipe-IT de produção concluídas) -**Último agente:** Codex +**Último agente:** Devin ## Resultado disponível @@ -59,6 +59,10 @@ - Health check Docker do Nginx sobre readiness. - Scripts operacionais de backup PostgreSQL custom com checksum e restauro de teste numa base temporária isolada. - Checklist versionado de produção, incluindo Entra, Snipe-IT, SMTP/TLS, segredos, monitorização, recuperação, segurança e piloto. +- Melhoria na validação do formulário de requisição: erros de validação são agora acumulados e mostrados simultaneamente em vez de parar no primeiro erro. +- Validação limpa erros de campos já corrigidos ao clicar em submeter, mantendo apenas os erros dos campos ainda incorretos. +- Endpoint `/requisicoes/verificar-disponibilidade-semanal` para verificar disponibilidade de equipamentos baseado em slots semanais. +- Botão "Verificar equipamentos livres" visível quando a repetição semanal está ativa, permitindo filtrar o catálogo pela próxima ocorrência semanal. ## Limites conhecidos @@ -72,7 +76,7 @@ ## Próxima tarefa recomendada -Implementar a **Fase 7 — hardening e entrada em produção**: +Continuar com a **Fase 7 — hardening e entrada em produção**: 1. configurar e validar Microsoft Entra no tenant institucional, incluindo Conditional Access/MFA; 2. confirmar com o responsável Snipe-IT a versão, o âmbito de menor privilégio dos tokens e as categorias/estados requisitáveis; validar checkout/check-in apenas no primeiro movimento institucional legítimo; @@ -84,44 +88,25 @@ Implementar a **Fase 7 — hardening e entrada em produção**: ## Testes da última sessão -Validação concluída em 5 de agosto de 2026: +Validação concluída em 15 de outubro de 2026: +- TypeScript: passou +- ESLint: passou +- Vite build: passou +- Validação de formulário corrigida: removido listener global de erros do Inertia e adicionado preserveState/preserveScroll para garantir que erros aparecem inline por baixo de cada campo + +Validação concluída em 14 de outubro de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 141 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 107 ficheiros, zero erros; -- Pest: 110 testes passaram, 617 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 147 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 109 ficheiros, zero erros; +- Pest: 113 testes passaram, 638 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - TypeScript (`tsc --noEmit`): passou; - ESLint: passou; - Prettier: passou; -- Vite: builds cliente e SSR concluídos, 2314 módulos cliente e 103 módulos SSR transformados; -- PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal; -- PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso; -- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados; -- PostgreSQL 17: migration `notifications` aplicada com sucesso; -- PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada; -- PostgreSQL 17: migration de `portal_settings` e buffers temporais aplicada; restrição GiST recriada sobre o intervalo com margem; -- smoke test HTTP: `/login` respondeu com estado 200 através do Nginx; -- Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`; -- Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia; -- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; aprovações, esclarecimentos e notificações foram validados por testes Inertia, TypeScript e builds cliente/SSR. -- testes focados de Microsoft Entra e health checks: 9 passaram, com 57 asserções; -- PHPStan/Larastan depois da integração OIDC: 107 ficheiros, zero erros; -- TypeScript, ESLint e Prettier depois das alterações de login/segurança: passaram; -- backup PostgreSQL real em formato custom criado com checksum e validado por `pg_restore --list`; -- restauro real numa base temporária: passou e confirmou 12 migrations antes da remoção automática; -- smoke tests via Nginx: `/health/live` devolveu `ok` e `/health/ready` devolveu `ready`. -- diagnóstico de login local anterior à ligação de produção: a base de desenvolvimento estava vazia apesar de migrada; `db:seed --force` repôs 2 utilizadores ativos, 5 perfis e, nesse contexto sem Snipe-IT configurado, 18 ativos fictícios; -- login real no browser validado com `demo@escola.pt`: redirecionou para `/dashboard` e não produziu erros de consola. -- testes focados das salvaguardas Snipe-IT: 17 passaram, com 106 asserções, incluindo prova de ausência de `POST` em ativos atribuídos e check-in sem proveniência ou destinatário válidos; -- tokens Snipe-IT confirmados como configurados e distintos; ambos responderam a uma consulta `GET` com sucesso, sem revelar credenciais ou URL; -- sincronização real exclusivamente de leitura concluída: 2367 ativos processados e criados no cache local, sem qualquer pedido de escrita no Snipe-IT; -- cache local após sincronização: 2367 ativos ativos, 716 atribuídos e 144 marcados como requisitáveis; -- frontend: TypeScript, ESLint, Prettier e build Vite passaram novamente; Composer Audit sem avisos conhecidos; -- smoke tests após a sincronização: liveness `ok`, readiness `ready` e `/login` HTTP 200. -- nova recuperação do login após deteção de 0 utilizadores/0 perfis: seeder tornado idempotente e restrito a `local`/`testing`; repôs 2 utilizadores e 5 perfis sem alterar os 2367 ativos reais no cache; -- login administrativo validado de ponta a ponta no browser integrado: `admin@escola.pt` redirecionou para `/dashboard` e apresentou as áreas administrativas; -- testes específicos do seeder: reposição repetida sem duplicados e preservação do cache Snipe-IT configurado; suite total com 110 testes e 617 asserções; -- regra permanente adicionada: testes Laravel usam `composer test`/SQLite em memória e nunca `php artisan test` diretamente sobre a configuração PostgreSQL local. +- Vite: builds cliente e SSR concluídos; +- Modal de detalhes do dia nos calendários pessoal e global implementado, mostrando todas as requisições de um dia com estado, horário, equipamentos e requisitante; +- Fuso horário da aplicação alterado de UTC para Europe/Lisbon para garantir consistência com o PHP e evitar discrepâncias de horário entre frontend e backend; +- Correção da conversão de data no frontend para usar fuso horário local em vez de UTC, evitando desvios de horário na data de repetição. ## Decisões pendentes do contexto institucional diff --git a/lang/pt/validation.php b/lang/pt/validation.php new file mode 100644 index 0000000..753bfed --- /dev/null +++ b/lang/pt/validation.php @@ -0,0 +1,170 @@ + 'O campo :attribute deve ser aceite.', + 'active_url' => 'O campo :attribute não é um URL válido.', + 'after' => 'O campo :attribute deve ser uma data posterior à data atual.', + 'after_or_equal' => 'O campo :attribute deve ser uma data posterior ou igual à data atual.', + 'alpha' => 'O campo :attribute só pode conter letras.', + 'alpha_dash' => 'O campo :attribute só pode conter letras, números e traços.', + 'alpha_num' => 'O campo :attribute só pode conter letras e números.', + 'array' => 'O campo :attribute deve ser um array.', + 'before' => 'O campo :attribute deve ser uma data anterior a :date.', + 'before_or_equal' => 'O campo :attribute deve ser uma data anterior ou igual a :date.', + 'between' => [ + 'numeric' => 'O campo :attribute deve estar entre :min e :max.', + 'file' => 'O campo :attribute deve ter entre :min e :max kilobytes.', + 'string' => 'O campo :attribute deve ter entre :min e :max caracteres.', + 'array' => 'O campo :attribute deve ter entre :min e :max itens.', + ], + 'boolean' => 'O campo :attribute deve ser verdadeiro ou falso.', + 'confirmed' => 'A confirmação do campo :attribute não coincide.', + 'current_password' => 'A palavra-passe está incorreta.', + 'date' => 'O campo :attribute não é uma data válida.', + 'date_equals' => 'O campo :attribute deve ser uma data igual a :date.', + 'date_format' => 'O campo :attribute não corresponde ao formato :format.', + 'declined' => 'O campo :attribute deve ser rejeitado.', + 'declined_if' => 'O campo :attribute deve ser rejeitado quando :other é :value.', + 'different' => 'O campo :attribute e :other devem ser diferentes.', + 'digits' => 'O campo :attribute deve ter :digits dígitos.', + 'digits_between' => 'O campo :attribute deve ter entre :min e :max dígitos.', + 'dimensions' => 'O campo :attribute tem dimensões de imagem inválidas.', + 'distinct' => 'O campo :attribute tem um valor duplicado.', + 'email' => 'O campo :attribute deve ser um endereço de email válido.', + 'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values.', + 'enum' => 'O :attribute selecionado é inválido.', + 'exists' => 'O :attribute selecionado é inválido.', + 'file' => 'O campo :attribute deve ser um ficheiro.', + 'filled' => 'O campo :attribute é obrigatório.', + 'gt' => [ + 'numeric' => 'O campo :attribute deve ser maior que :value.', + 'file' => 'O campo :attribute deve ter mais de :value kilobytes.', + 'string' => 'O campo :attribute deve ter mais de :value caracteres.', + 'array' => 'O campo :attribute deve ter mais de :value itens.', + ], + 'gte' => [ + 'numeric' => 'O campo :attribute deve ser maior ou igual a :value.', + 'file' => 'O campo :attribute deve ter pelo menos :value kilobytes.', + 'string' => 'O campo :attribute deve ter pelo menos :value caracteres.', + 'array' => 'O campo :attribute deve ter pelo menos :value itens.', + ], + 'image' => 'O campo :attribute deve ser uma imagem.', + 'in' => 'O :attribute selecionado é inválido.', + 'in_array' => 'O campo :attribute não existe em :other.', + 'integer' => 'O campo :attribute deve ser um número inteiro.', + 'ip' => 'O campo :attribute deve ser um endereço IP válido.', + 'ipv4' => 'O campo :attribute deve ser um endereço IPv4 válido.', + 'ipv6' => 'O campo :attribute deve ser um endereço IPv6 válido.', + 'json' => 'O campo :attribute deve ser uma string JSON válida.', + 'lt' => [ + 'numeric' => 'O campo :attribute deve ser menor que :value.', + 'file' => 'O campo :attribute deve ter menos de :value kilobytes.', + 'string' => 'O campo :attribute deve ter menos de :value caracteres.', + 'array' => 'O campo :attribute deve ter menos de :value itens.', + ], + 'lte' => [ + 'numeric' => 'O campo :attribute deve ser menor ou igual a :value.', + 'file' => 'O campo :attribute deve ter no máximo :value kilobytes.', + 'string' => 'O campo :attribute deve ter no máximo :value caracteres.', + 'array' => 'O campo :attribute deve ter no máximo :value itens.', + ], + 'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.', + 'max' => [ + 'numeric' => 'O campo :attribute não pode ser superior a :max.', + 'file' => 'O campo :attribute não pode ter mais de :max kilobytes.', + 'string' => 'O campo :attribute não pode ter mais de :max caracteres.', + 'array' => 'O campo :attribute não pode ter mais de :max itens.', + ], + 'mimes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.', + 'mimetypes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.', + 'min' => [ + 'numeric' => 'O campo :attribute deve ser pelo menos :min.', + 'file' => 'O campo :attribute deve ter pelo menos :min kilobytes.', + 'string' => 'O campo :attribute deve ter pelo menos :min caracteres.', + 'array' => 'O campo :attribute deve ter pelo menos :min itens.', + ], + 'multiple_of' => 'O campo :attribute deve ser múltiplo de :value.', + 'not_in' => 'O :attribute selecionado é inválido.', + 'not_regex' => 'O formato do campo :attribute é inválido.', + 'numeric' => 'O campo :attribute deve ser um número.', + 'password' => 'A palavra-passe está incorreta.', + 'present' => 'O campo :attribute deve estar presente.', + 'prohibited' => 'O campo :attribute é proibido.', + 'prohibited_if' => 'O campo :attribute é proibido quando :other é :value.', + 'prohibited_unless' => 'O campo :attribute é proibido a menos que :other esteja em :values.', + 'prohibits' => 'O campo :attribute proíbe :other de estar presente.', + 'regex' => 'O formato do campo :attribute é inválido.', + 'required' => 'Este campo é obrigatório.', + 'required_array_keys' => 'O campo :attribute deve conter as chaves: :values.', + 'required_if' => 'Este campo é obrigatório quando :other é :value.', + 'required_unless' => 'Este campo é obrigatório a menos que :other esteja em :values.', + 'required_with' => 'Este campo é obrigatório quando :values está presente.', + 'required_with_all' => 'Este campo é obrigatório quando :values estão presentes.', + 'required_without' => 'Este campo é obrigatório quando :values não está presente.', + 'required_without_all' => 'Este campo é obrigatório quando nenhum de :values está presente.', + 'same' => 'O campo :attribute e :other devem coincidir.', + 'size' => [ + 'numeric' => 'O campo :attribute deve ser :size.', + 'file' => 'O campo :attribute deve ter :size kilobytes.', + 'string' => 'O campo :attribute deve ter :size caracteres.', + 'array' => 'O campo :attribute deve conter :size itens.', + ], + 'starts_with' => 'O campo :attribute deve começar com um dos seguintes: :values.', + 'string' => 'O campo :attribute deve ser uma string.', + 'timezone' => 'O campo :attribute deve ser um fuso horário válido.', + 'unique' => 'O :attribute já está a ser utilizado.', + 'uploaded' => 'O falha no upload do campo :attribute.', + 'url' => 'O campo :attribute deve ser um URL válido.', + 'uuid' => 'O campo :attribute deve ser um UUID válido.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'starts_at.after' => 'A data de início deve ser posterior à data atual.', + 'starts_at.after_or_equal' => 'A data de início deve ser posterior ou igual à data atual.', + 'ends_at.after' => 'A data de fim deve ser posterior à data de início.', + 'ends_at.after_or_equal' => 'A data de fim deve ser posterior ou igual à data atual.', + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [ + 'purpose' => 'finalidade', + 'usage_location' => 'local de utilização', + 'starts_at' => 'data de início', + 'ends_at' => 'data de fim', + 'asset_ids' => 'equipamentos', + 'terms_accepted' => 'aceitação dos termos', + 'email' => 'email', + 'password' => 'palavra-passe', + ], +]; diff --git a/lang/pt_PT/validation.php b/lang/pt_PT/validation.php new file mode 100644 index 0000000..0a44e4c --- /dev/null +++ b/lang/pt_PT/validation.php @@ -0,0 +1,170 @@ + 'O campo :attribute deve ser aceite.', + 'active_url' => 'O campo :attribute não é um URL válido.', + 'after' => 'O campo :attribute deve ser uma data posterior à data atual.', + 'after_or_equal' => 'O campo :attribute deve ser uma data posterior ou igual à data atual.', + 'alpha' => 'O campo :attribute só pode conter letras.', + 'alpha_dash' => 'O campo :attribute só pode conter letras, números e traços.', + 'alpha_num' => 'O campo :attribute só pode conter letras e números.', + 'array' => 'O campo :attribute deve ser um array.', + 'before' => 'O campo :attribute deve ser uma data anterior a :date.', + 'before_or_equal' => 'O campo :attribute deve ser uma data anterior ou igual a :date.', + 'between' => [ + 'numeric' => 'O campo :attribute deve estar entre :min e :max.', + 'file' => 'O campo :attribute deve ter entre :min e :max kilobytes.', + 'string' => 'O campo :attribute deve ter entre :min e :max caracteres.', + 'array' => 'O campo :attribute deve ter entre :min e :max itens.', + ], + 'boolean' => 'O campo :attribute deve ser verdadeiro ou falso.', + 'confirmed' => 'A confirmação do campo :attribute não coincide.', + 'current_password' => 'A palavra-passe está incorreta.', + 'date' => 'O campo :attribute não é uma data válida.', + 'date_equals' => 'O campo :attribute deve ser uma data igual a :date.', + 'date_format' => 'O campo :attribute não corresponde ao formato :format.', + 'declined' => 'O campo :attribute deve ser rejeitado.', + 'declined_if' => 'O campo :attribute deve ser rejeitado quando :other é :value.', + 'different' => 'O campo :attribute e :other devem ser diferentes.', + 'digits' => 'O campo :attribute deve ter :digits dígitos.', + 'digits_between' => 'O campo :attribute deve ter entre :min e :max dígitos.', + 'dimensions' => 'O campo :attribute tem dimensões de imagem inválidas.', + 'distinct' => 'O campo :attribute tem um valor duplicado.', + 'email' => 'O campo :attribute deve ser um endereço de email válido.', + 'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values.', + 'enum' => 'O :attribute selecionado é inválido.', + 'exists' => 'O :attribute selecionado é inválido.', + 'file' => 'O campo :attribute deve ser um ficheiro.', + 'filled' => 'O campo :attribute é obrigatório.', + 'gt' => [ + 'numeric' => 'O campo :attribute deve ser maior que :value.', + 'file' => 'O campo :attribute deve ter mais de :value kilobytes.', + 'string' => 'O campo :attribute deve ter mais de :value caracteres.', + 'array' => 'O campo :attribute deve ter mais de :value itens.', + ], + 'gte' => [ + 'numeric' => 'O campo :attribute deve ser maior ou igual a :value.', + 'file' => 'O campo :attribute deve ter pelo menos :value kilobytes.', + 'string' => 'O campo :attribute deve ter pelo menos :value caracteres.', + 'array' => 'O campo :attribute deve ter pelo menos :value itens.', + ], + 'image' => 'O campo :attribute deve ser uma imagem.', + 'in' => 'O :attribute selecionado é inválido.', + 'in_array' => 'O campo :attribute não existe em :other.', + 'integer' => 'O campo :attribute deve ser um número inteiro.', + 'ip' => 'O campo :attribute deve ser um endereço IP válido.', + 'ipv4' => 'O campo :attribute deve ser um endereço IPv4 válido.', + 'ipv6' => 'O campo :attribute deve ser um endereço IPv6 válido.', + 'json' => 'O campo :attribute deve ser uma string JSON válida.', + 'lt' => [ + 'numeric' => 'O campo :attribute deve ser menor que :value.', + 'file' => 'O campo :attribute deve ter menos de :value kilobytes.', + 'string' => 'O campo :attribute deve ter menos de :value caracteres.', + 'array' => 'O campo :attribute deve ter menos de :value itens.', + ], + 'lte' => [ + 'numeric' => 'O campo :attribute deve ser menor ou igual a :value.', + 'file' => 'O campo :attribute deve ter no máximo :value kilobytes.', + 'string' => 'O campo :attribute deve ter no máximo :value caracteres.', + 'array' => 'O campo :attribute deve ter no máximo :value itens.', + ], + 'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.', + 'max' => [ + 'numeric' => 'O campo :attribute não pode ser superior a :max.', + 'file' => 'O campo :attribute não pode ter mais de :max kilobytes.', + 'string' => 'O campo :attribute não pode ter mais de :max caracteres.', + 'array' => 'O campo :attribute não pode ter mais de :max itens.', + ], + 'mimes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.', + 'mimetypes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.', + 'min' => [ + 'numeric' => 'O campo :attribute deve ser pelo menos :min.', + 'file' => 'O campo :attribute deve ter pelo menos :min kilobytes.', + 'string' => 'O campo :attribute deve ter pelo menos :min caracteres.', + 'array' => 'O campo :attribute deve ter pelo menos :min itens.', + ], + 'multiple_of' => 'O campo :attribute deve ser múltiplo de :value.', + 'not_in' => 'O :attribute selecionado é inválido.', + 'not_regex' => 'O formato do campo :attribute é inválido.', + 'numeric' => 'O campo :attribute deve ser um número.', + 'password' => 'A palavra-passe está incorreta.', + 'present' => 'O campo :attribute deve estar presente.', + 'prohibited' => 'O campo :attribute é proibido.', + 'prohibited_if' => 'O campo :attribute é proibido quando :other é :value.', + 'prohibited_unless' => 'O campo :attribute é proibido a menos que :other esteja em :values.', + 'prohibits' => 'O campo :attribute proíbe :other de estar presente.', + 'regex' => 'O formato do campo :attribute é inválido.', + 'required' => 'Este campo é obrigatório.', + 'required_array_keys' => 'O campo :attribute deve conter as chaves: :values.', + 'required_if' => 'Este campo é obrigatório quando :other é :value.', + 'required_unless' => 'Este campo é obrigatório a menos que :other esteja em :values.', + 'required_with' => 'Este campo é obrigatório quando :values está presente.', + 'required_with_all' => 'Este campo é obrigatório quando :values estão presentes.', + 'required_without' => 'Este campo é obrigatório quando :values não está presente.', + 'required_without_all' => 'Este campo é obrigatório quando nenhum de :values está presente.', + 'same' => 'O campo :attribute e :other devem coincidir.', + 'size' => [ + 'numeric' => 'O campo :attribute deve ser :size.', + 'file' => 'O campo :attribute deve ter :size kilobytes.', + 'string' => 'O campo :attribute deve ter :size caracteres.', + 'array' => 'O campo :attribute deve conter :size itens.', + ], + 'starts_with' => 'O campo :attribute deve começar com um dos seguintes: :values.', + 'string' => 'O campo :attribute deve ser uma string.', + 'timezone' => 'O campo :attribute deve ser um fuso horário válido.', + 'unique' => 'O :attribute já está a ser utilizado.', + 'uploaded' => 'O falha no upload do campo :attribute.', + 'url' => 'O campo :attribute deve ser um URL válido.', + 'uuid' => 'O campo :attribute deve ser um UUID válido.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'starts_at.after' => 'A data de início deve ser posterior à data atual.', + 'starts_at.after_or_equal' => 'A data de início deve ser posterior ou igual à data atual.', + 'ends_at.after' => 'A data de fim deve ser posterior à data de início.', + 'ends_at.after_or_equal' => 'A data de fim deve ser posterior ou igual à data de início.', + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [ + 'purpose' => 'finalidade', + 'usage_location' => 'local de utilização', + 'starts_at' => 'data de início', + 'ends_at' => 'data de fim', + 'asset_ids' => 'equipamentos', + 'terms_accepted' => 'aceitação dos termos', + 'email' => 'email', + 'password' => 'palavra-passe', + ], +]; diff --git a/resources/js/components/recurring-conflict-modal.tsx b/resources/js/components/recurring-conflict-modal.tsx new file mode 100644 index 0000000..06e337e --- /dev/null +++ b/resources/js/components/recurring-conflict-modal.tsx @@ -0,0 +1,690 @@ +import { + AlertTriangle, + ArrowRight, + Calendar, + CheckCircle2, + Clock, + Package, + RefreshCw, + Trash2, + Undo2, + X, +} from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import type { Asset, OccurrenceCheck } from '@/types'; + +type RecurringConflictModalProps = { + isOpen: boolean; + onClose: () => void; + isLoading: boolean; + checkResult: { + occurrences: OccurrenceCheck[]; + total_occurrences: number; + conflicts_count: number; + } | null; + initialAssetIds: number[]; + isSubmitting: boolean; + onConfirmSubmit: ( + occurrencesToSubmit: Array<{ + starts_at: string; + ends_at: string; + asset_ids: number[]; + }>, + ) => void; +}; + +export function RecurringConflictModal({ + isOpen, + onClose, + isLoading, + checkResult, + initialAssetIds, + isSubmitting, + onConfirmSubmit, +}: RecurringConflictModalProps) { + const [omittedIndexes, setOmittedIndexes] = useState([]); + // Key format: `${occurrenceIndex}_${originalAssetId}` -> replacement Asset + const [replacements, setReplacements] = useState>({}); + const [activeSelectorKey, setActiveSelectorKey] = useState( + null, + ); + + const occurrences = checkResult?.occurrences ?? []; + + function toggleOmit(index: number) { + setOmittedIndexes((prev) => + prev.includes(index) + ? prev.filter((i) => i !== index) + : [...prev, index], + ); + } + + function toggleReplacementSelector(key: string) { + setActiveSelectorKey((prev) => (prev === key ? null : key)); + } + + function selectReplacement( + occIndex: number, + originalAssetId: number, + replacement: Asset, + ) { + setReplacements((prev) => ({ + ...prev, + [`${occIndex}_${originalAssetId}`]: replacement, + })); + setActiveSelectorKey(null); + } + + function removeReplacement(occIndex: number, originalAssetId: number) { + setReplacements((prev) => { + const next = { ...prev }; + delete next[`${occIndex}_${originalAssetId}`]; + + return next; + }); + } + + const activeOccurrences = occurrences.filter( + (_, idx) => !omittedIndexes.includes(idx), + ); + const omittedCount = occurrences.length - activeOccurrences.length; + + // Check if there are unresolved conflicts in active occurrences + const hasUnresolvedConflicts = occurrences.some((check, idx) => { + if (omittedIndexes.includes(idx) || !check.has_conflict) { + return false; + } + + return check.conflicted_assets.some( + (ca) => !replacements[`${idx}_${ca.id}`], + ); + }); + + const canSubmit = + !isLoading && + activeOccurrences.length > 0 && + !hasUnresolvedConflicts && + !isSubmitting; + + function handleConfirm() { + const payload = occurrences + .map((check, index) => ({ check, index })) + .filter(({ index }) => !omittedIndexes.includes(index)) + .map(({ check, index }) => { + const finalAssetIds = initialAssetIds.map((origId) => { + const replacement = replacements[`${index}_${origId}`]; + + return replacement ? replacement.id : origId; + }); + + return { + starts_at: check.starts_at, + ends_at: check.ends_at, + asset_ids: finalAssetIds, + }; + }); + + onConfirmSubmit(payload); + } + + return ( + !open && onClose()}> + + + + + Confirmação de Série Semanal + + + Análise de disponibilidade de todas as semanas da sua + requisição recorrente. + + + +
+ {isLoading ? ( +
+ +

+ A verificar disponibilidade semana a semana… +

+

+ A cruzar horários com o inventário e requisições + existentes. +

+
+ ) : checkResult ? ( + <> + {/* Summary banner */} +
+
+ + Semanas totais + +

+ {checkResult.total_occurrences} +

+
+
+ + A requisitar + +

+ {activeOccurrences.length} +

+
+
+ + Conflitos detetados + +

0 + ? 'text-amber-600 dark:text-amber-500' + : 'text-emerald-600 dark:text-emerald-500' + }`} + > + {checkResult.conflicts_count} +

+
+
+ + {/* Informative alert */} + {checkResult.conflicts_count > 0 ? ( +
+
+ +
+

+ Existem conflitos de horário com + outros pedidos! +

+

+ Para cada dia assinalado, + escolha se prefere{' '} + + apagar a reserva desse dia + {' '} + (excluindo-o da série) ou{' '} + + trocar o material + {' '} + por outro equipamento + disponível. +

+
+
+
+ ) : ( +
+
+ +

+ Todos os equipamentos estão + disponíveis para todas as semanas da + série! +

+
+
+ )} + + {/* Occurrence Cards List */} +
+ {occurrences.map((check, index) => { + const isOmitted = + omittedIndexes.includes(index); + const hasConflicts = check.has_conflict; + const allConflictsResolved = + hasConflicts && + check.conflicted_assets.every( + (ca) => + replacements[ + `${index}_${ca.id}` + ], + ); + + return ( +
+
+
+
+ + Semana {index + 1} + +

+ { + check.date_formatted + } +

+ + + { + check.time_formatted + } + +
+ +
+ {isOmitted ? ( + + Dia apagado / + ignorado + + ) : allConflictsResolved ? ( + + Conflito + resolvido com + troca + + ) : hasConflicts ? ( + + Conflito de + material + + ) : ( + + Equipamento + disponível + + )} +
+
+ +
+ {isOmitted ? ( + + ) : ( + + )} +
+
+ + {/* Details of items & conflicts */} + {!isOmitted && ( +
+ {check.conflicted_assets.map( + (cAsset) => { + const key = `${index}_${cAsset.id}`; + const replacement = + replacements[ + key + ]; + const isSelecting = + activeSelectorKey === + key; + + return ( +
+
+
+ + Material + em + conflito:{' '} + + + { + cAsset.name + }{' '} + ( + { + cAsset.asset_tag + } + + ) + +

+ Já + se + encontra + reservado + por + outro + utilizador + para + este + horário. +

+
+ +
+ {replacement ? ( + + ) : ( + + )} +
+
+ + {/* Replacement indicator */} + {replacement && ( +
+
+ + + Substituído + por:{' '} + + { + replacement.name + } + {' '} + ( + { + replacement.asset_tag + } + + ) + +
+
+ )} + + {/* Interactive Alternatives Selector */} + {isSelecting && + !replacement && ( +
+
+ + Equipamentos + disponíveis + para + este + dia + ( + { + cAsset + .alternatives + .length + } + ): + + +
+ + {cAsset + .alternatives + .length === + 0 ? ( +

+ Não + foram + encontrados + equipamentos + alternativos + livres + para + este + horário. + Sugerimos + apagar + a + reserva + deste + dia. +

+ ) : ( +
+ {cAsset.alternatives.map( + ( + alt, + ) => ( +
+ selectReplacement( + index, + cAsset.id, + alt, + ) + } + > +
+ + { + alt.name + } + + + { + alt.asset_tag + } + + {alt.category_name && ( + + ·{' '} + { + alt.category_name + } + + )} +
+ +
+ ), + )} +
+ )} +
+ )} +
+ ); + }, + )} + + {/* Available assets for this occurrence */} + {check.available_assets + .length > 0 && ( +
+ + + Disponíveis: + + + {check.available_assets + .map( + (a) => + `${a.name} (${a.asset_tag})`, + ) + .join(', ')} + +
+ )} +
+ )} +
+ ); + })} +
+ + ) : null} +
+ + +
+ {activeOccurrences.length > 0 ? ( + + A submeter{' '} + {activeOccurrences.length}{' '} + requisições + {omittedCount > 0 + ? ` (${omittedCount} dias ignorados)` + : ''} + . + + ) : ( + + Todos os dias foram apagados. Restaure pelo + menos um dia para submeter. + + )} + {hasUnresolvedConflicts && ( + + ⚠ Resolva os conflitos pendentes (trocando o + material ou apagando o dia). + + )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/resources/js/components/weekly-slot-selector.tsx b/resources/js/components/weekly-slot-selector.tsx new file mode 100644 index 0000000..ce111ae --- /dev/null +++ b/resources/js/components/weekly-slot-selector.tsx @@ -0,0 +1,216 @@ +import { Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import type { WeeklySlot } from '@/types'; + +interface WeeklySlotSelectorProps { + weeklySlots: WeeklySlot[]; + onChange: (slots: WeeklySlot[]) => void; + slotAvailability?: Map; + weekOffset?: number; +} + +const timeSlots = [ + { start: '08:30', end: '09:30' }, + { start: '09:40', end: '10:40' }, + { start: '10:50', end: '11:50' }, + { start: '11:50', end: '12:50' }, + { start: '13:00', end: '14:00' }, + { start: '14:00', end: '15:00' }, + { start: '15:10', end: '16:10' }, + { start: '16:20', end: '17:20' }, + { start: '17:20', end: '18:20' }, +]; + +const weekDays = [ + { value: 1, label: 'Seg' }, + { value: 2, label: 'Ter' }, + { value: 3, label: 'Qua' }, + { value: 4, label: 'Qui' }, + { value: 5, label: 'Sex' }, +]; + +export function WeeklySlotSelector({ + weeklySlots, + onChange, + slotAvailability, + weekOffset = 0, +}: WeeklySlotSelectorProps) { + // Calculate dates for the week + const getWeekDates = (offset: number) => { + const now = new Date(); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // Monday + + const dates: Record = {}; + + for (let i = 0; i < 5; i++) { + const date = new Date(startOfWeek); + date.setDate(startOfWeek.getDate() + i); + dates[i + 1] = date; + } + + return dates; + }; + + const weekDates = getWeekDates(weekOffset); + + const formatDate = (date: Date) => { + return date.toLocaleDateString('pt-PT', { + day: '2-digit', + month: '2-digit', + }); + }; + + const isSlotSelected = (day: number, start: string, end: string) => { + return weeklySlots.some( + (slot) => + slot.day_of_week === day && + slot.start_time === start && + slot.end_time === end, + ); + }; + + const isSlotAvailable = (day: number, start: string, end: string) => { + if (!slotAvailability) { +return true; +} + + const key = `${day}-${start}-${end}`; + + return slotAvailability.get(key) ?? true; + }; + + const toggleSlot = (day: number, start: string, end: string) => { + if (!isSlotAvailable(day, start, end)) { + return; // Don't allow selecting unavailable slots + } + + const existingIndex = weeklySlots.findIndex( + (slot) => + slot.day_of_week === day && + slot.start_time === start && + slot.end_time === end, + ); + + if (existingIndex >= 0) { + // Remove slot + const newSlots = weeklySlots.filter((_, i) => i !== existingIndex); + onChange(newSlots); + } else { + // Add slot + onChange([ + ...weeklySlots, + { day_of_week: day, start_time: start, end_time: end }, + ]); + } + }; + + const clearAll = () => { + onChange([]); + }; + + return ( +
+
+ + {weeklySlots.length > 0 && ( + + )} +
+ +
+
+ {/* Header row with day names */} +
+
+ {weekDays.map((day) => ( +
+ {day.label} {formatDate(weekDates[day.value])} +
+ ))} +
+ + {/* Time slots rows */} + {timeSlots.map((time) => ( +
+
+ {time.start}-{time.end} +
+ {weekDays.map((day) => { + const selected = isSlotSelected( + day.value, + time.start, + time.end, + ); + const available = isSlotAvailable( + day.value, + time.start, + time.end, + ); + + return ( + + ); + })} +
+ ))} +
+
+ + {weeklySlots.length === 0 && ( +
+ Selecione pelo menos um horário para continuar +
+ )} +
+ ); +} diff --git a/resources/js/pages/assets/index.tsx b/resources/js/pages/assets/index.tsx index 40bccdd..c857b48 100644 --- a/resources/js/pages/assets/index.tsx +++ b/resources/js/pages/assets/index.tsx @@ -2,10 +2,13 @@ import { Head, Link, router } from '@inertiajs/react'; import { Boxes, CalendarClock, + FilterX, MapPin, PackageCheck, Search, SlidersHorizontal, + ChevronLeft, + ChevronRight, } from 'lucide-react'; import type { FormEvent } from 'react'; import { useState } from 'react'; @@ -67,6 +70,22 @@ export default function AssetsIndex({ ); } + function clearFilters() { + setSearch(''); + setCategory('all'); + setLocation('all'); + setStartsAt(''); + setEndsAt(''); + router.get('/equipamentos', {}, { preserveState: true }); + } + + const hasActiveFilters = + search || + category !== 'all' || + location !== 'all' || + startsAt || + endsAt; + return ( <> @@ -77,81 +96,121 @@ export default function AssetsIndex({ description="Consulte os equipamentos requisitáveis registados no Snipe-IT e inicie uma requisição." /> -
-
- - setSearch(event.target.value)} - placeholder="Nome, modelo ou etiqueta…" - className="pl-9" - /> -
- -
- - - setStartsAt(event.target.value) - } - className="pl-9" - required={Boolean(endsAt)} - /> -
- setEndsAt(event.target.value)} - required={Boolean(startsAt)} - /> - - -
+ + +
+
+ + + setSearch(event.target.value) + } + placeholder="Pesquisar equipamentos por nome, modelo ou etiqueta…" + className="h-12 pl-10 text-base" + /> +
+
+ + + Mais filtros + +
+ +
+ + + setStartsAt(event.target.value) + } + className="pl-9" + required={Boolean(endsAt)} + /> +
+ + setEndsAt(event.target.value) + } + required={Boolean(startsAt)} + /> + +
+ + {hasActiveFilters && ( + + )} +
+
+
+
+
+
- + {assets.total} equipamento {assets.total === 1 ? '' : 's'} {lastSyncedAt && ( - + + Sincronizado em{' '} {new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', @@ -182,36 +241,82 @@ export default function AssetsIndex({

Altere os filtros e volte a tentar.

+ {hasActiveFilters && ( + + )}
)} {assets.last_page > 1 && ( )} @@ -238,35 +343,44 @@ function AssetCard({ } return ( - -
+ +
{asset.image_url ? ( ) : ( - + )} + + {periodSelected ? 'Livre no período' : 'Requisitável'} +
-
-

{asset.name}

-

+

+

{asset.name}

+

{asset.model_name}

- - {periodSelected ? 'Livre no período' : 'Requisitável'} -
- {asset.asset_tag} - {asset.category_name && {asset.category_name}} + + {asset.asset_tag} + + {asset.category_name && ( + + {asset.category_name} + + )} {asset.location_name && ( - + {asset.location_name} diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 4ed0e1a..aacd9a8 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -1,9 +1,11 @@ import { Form, Head } from '@inertiajs/react'; +import { Laptop2, ShieldCheck } from 'lucide-react'; import InputError from '@/components/input-error'; import PasskeyVerify from '@/components/passkey-verify'; import PasswordInput from '@/components/password-input'; import TextLink from '@/components/text-link'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -26,6 +28,18 @@ export default function Login({ <> +
+
+ +
+

+ Portal de Reservas +

+

+ Sistema de gestão de equipamentos da escola +

+
+ {({ processing, errors }) => ( <> -
+
@@ -93,6 +108,7 @@ export default function Login({ tabIndex={2} autoComplete="current-password" placeholder="Palavra-passe" + className="h-11" />
@@ -103,12 +119,14 @@ export default function Login({ name="remember" tabIndex={3} /> - +
-

- O acesso é atribuído pela escola. Contacte os - Serviços de Informática se ainda não tiver conta. -

+ + + +
+

+ Acesso seguro +

+

+ O acesso é atribuído pela escola. + Contacte os Serviços de Informática se + ainda não tiver conta. +

+
+
+
)} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 2e07c1d..446ed40 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -6,6 +6,7 @@ import { Clock3, PackageCheck, Plus, + TrendingUp, } from 'lucide-react'; import { PageHeading } from '@/components/page-heading'; import { ReservationStatus } from '@/components/reservation-status'; @@ -206,23 +207,42 @@ function SummaryCard({ tone: 'amber' | 'emerald' | 'blue'; }) { const colors = { - amber: 'bg-amber-50 text-amber-700', - emerald: 'bg-emerald-50 text-emerald-700', - blue: 'bg-blue-50 text-blue-700', + amber: { + bg: 'bg-amber-50 dark:bg-amber-950/30', + text: 'text-amber-700 dark:text-amber-400', + icon: 'bg-amber-100 dark:bg-amber-900/50', + }, + emerald: { + bg: 'bg-emerald-50 dark:bg-emerald-950/30', + text: 'text-emerald-700 dark:text-emerald-400', + icon: 'bg-emerald-100 dark:bg-emerald-900/50', + }, + blue: { + bg: 'bg-blue-50 dark:bg-blue-950/30', + text: 'text-blue-700 dark:text-blue-400', + icon: 'bg-blue-100 dark:bg-blue-900/50', + }, }; return ( - - + + - + -
-

{value}

-

{label}

+
+

{value}

+

+ {label} +

+ {value > 0 && ( + + )} ); diff --git a/resources/js/pages/reservations/calendar.tsx b/resources/js/pages/reservations/calendar.tsx index d033452..dfbeb89 100644 --- a/resources/js/pages/reservations/calendar.tsx +++ b/resources/js/pages/reservations/calendar.tsx @@ -1,23 +1,48 @@ -import { Head, Link } from '@inertiajs/react'; -import { ArrowLeft, ArrowRight, CalendarDays, Plus } from 'lucide-react'; +import { Head, Link, router } from '@inertiajs/react'; +import { ArrowLeft, ArrowRight, CalendarDays, Clock, Filter, Package, Plus, User, X } from 'lucide-react'; +import { useState } from 'react'; + import { PageHeading } from '@/components/page-heading'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { cn } from '@/lib/utils'; import type { Reservation } from '@/types'; const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom']; +const statusOptions = [ + { value: '', label: 'Todos os estados' }, + { value: 'pending', label: 'Pendente' }, + { value: 'confirmed', label: 'Confirmada ou em curso' }, + { value: 'closed', label: 'Fechada sem utilização' }, +]; + export default function ReservationCalendar({ month, reservations, scope = 'personal', + status = '', }: { month: string; reservations: (Reservation & { requester?: { name: string; department: string | null }; })[]; scope?: 'personal' | 'global'; + status?: string; }) { + const [selectedDay, setSelectedDay] = useState(null); const [year, monthNumber] = month.split('-').map(Number); const monthStart = new Date(year, monthNumber - 1, 1); const firstWeekday = (monthStart.getDay() + 6) % 7; @@ -41,6 +66,59 @@ export default function ReservationCalendar({ ? '/calendario-global' : '/requisicoes/calendario'; + function handleStatusChange(value: string) { + router.get(calendarUrl, { month, status: value || undefined }, { + preserveState: true, + preserveScroll: true, + }); + } + + function handleDayClick(day: Date) { + setSelectedDay(day); + } + + function formatDate(date: Date): string { + return date.toLocaleDateString('pt-PT', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + + function formatTime(dateString: string): string { + const date = new Date(dateString); + + return date.toLocaleTimeString('pt-PT', { + hour: '2-digit', + minute: '2-digit', + }); + } + + function getStatusLabel(status: string): string { + if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) { + return 'Fechada sem utilização'; + } + + if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) { + return 'Confirmada ou em curso'; + } + + return 'Pendente'; + } + + function getStatusColor(status: string): string { + if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) { + return 'bg-slate-400'; + } + + if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) { + return 'bg-emerald-500'; + } + + return 'bg-amber-500'; + } + return ( <> @@ -67,6 +145,32 @@ export default function ReservationCalendar({ ) : undefined } /> +
+
+ + + {status && ( + + )} +
+
@@ -118,10 +222,12 @@ export default function ReservationCalendar({ return (
entries.length > 0 && handleDayClick(day)} className={cn( - 'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2', + 'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2 cursor-pointer', !isCurrentMonth && 'bg-slate-50/70 text-muted-foreground dark:bg-slate-950/40', + entries.length > 0 && 'hover:bg-slate-50 dark:hover:bg-slate-900/50', )} > )} + + setSelectedDay(null)}> + + + Requisições do dia + + {selectedDay && (() => { + const dayStart = new Date( + selectedDay.getFullYear(), + selectedDay.getMonth(), + selectedDay.getDate(), + ); + const dayEnd = new Date( + selectedDay.getFullYear(), + selectedDay.getMonth(), + selectedDay.getDate() + 1, + ); + const dayEntries = reservations.filter( + (reservation) => + new Date(reservation.starts_at) < dayEnd && + new Date(reservation.ends_at) > dayStart, + ); + + return ( +
+

+ {formatDate(selectedDay)} +

+ {dayEntries.length === 0 ? ( +

+ Não existem requisições neste dia. +

+ ) : ( +
+ {dayEntries.map((reservation) => ( +
+
+
+
+ + {reservation.reference} + + + {getStatusLabel(reservation.status)} + +
+ {global && reservation.requester && ( +
+ + {reservation.requester.name} + {reservation.requester.department && ( + · {reservation.requester.department} + )} +
+ )} +
+ + + {formatTime(reservation.starts_at)} - {formatTime(reservation.ends_at)} + +
+
+ + + {reservation.items.map((item) => item.asset.name).join(', ')} + +
+
+
+
+ ))} +
+ )} +
+ ); + })()} +
+
); diff --git a/resources/js/pages/reservations/create.tsx b/resources/js/pages/reservations/create.tsx index 7e4bf5d..6e6f61c 100644 --- a/resources/js/pages/reservations/create.tsx +++ b/resources/js/pages/reservations/create.tsx @@ -3,18 +3,27 @@ import { ArrowLeft, CalendarClock, Check, + Info, MapPin, PackageCheck, + RefreshCw, + Repeat, + Search, + CheckSquare, + Trash2, } from 'lucide-react'; import type { FormEvent } from 'react'; +import { useState } from 'react'; import InputError from '@/components/input-error'; import { PageHeading } from '@/components/page-heading'; +import { RecurringConflictModal } from '@/components/recurring-conflict-modal'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import type { Asset } from '@/types'; +import { WeeklySlotSelector } from '@/components/weekly-slot-selector'; +import type { Asset, OccurrenceCheck, WeeklySlot } from '@/types'; export default function CreateReservation({ assets, @@ -42,9 +51,373 @@ export default function CreateReservation({ asset_ids: selectedAssetId ? [selectedAssetId] : ([] as number[]), terms_accepted: false, }); - function submit(event: FormEvent) { + const [search, setSearch] = useState(''); + const [expandedGroups, setExpandedGroups] = useState>( + new Set(), + ); + + // Slot selector mode for non-recurring reservations + const [useSlotSelector, setUseSlotSelector] = useState(true); + + const [filteredAssets, setFilteredAssets] = useState(useSlotSelector ? [] : assets); + + // Recurrence states + const [isRecurring, setIsRecurring] = useState(false); + const [recurrenceMode, setRecurrenceMode] = useState< + 'until_date' | 'weeks_count' + >('until_date'); + const [repeatUntil, setRepeatUntil] = useState(''); + const [weeksCount, setWeeksCount] = useState(4); + const [weeklySlots, setWeeklySlots] = useState([]); + + // Slot selector mode for recurring reservations + const [useRecurringSlotSelector, setUseRecurringSlotSelector] = useState(true); + const [slotAvailability, setSlotAvailability] = useState< + Map + >(new Map()); + + // Week navigation for non-recurring reservations + const [nonRecurringWeekOffset, setNonRecurringWeekOffset] = useState(0); + + // Week navigation for manual time entry + const [weekOffset, setWeekOffset] = useState(0); + const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState([1]); // Default to Monday + + const [showConflictModal, setShowConflictModal] = useState(false); + const [isCheckingRecurrence, setIsCheckingRecurrence] = useState(false); + const [checkResult, setCheckResult] = useState<{ + occurrences: OccurrenceCheck[]; + total_occurrences: number; + conflicts_count: number; + } | null>(null); + const [isSubmittingSeries, setIsSubmittingSeries] = useState(false); + const [generalError, setGeneralError] = useState(''); + + // Helper function to get week date range + const getWeekDateRange = (offset: number) => { + const now = new Date(); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // Monday + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 4); // Friday + + const formatDate = (date: Date) => { + return date.toLocaleDateString('pt-PT', { + day: '2-digit', + month: '2-digit', + }); + }; + + return `${formatDate(startOfWeek)}-${formatDate(endOfWeek)}`; + }; + + const recurringSummary = (() => { + if (weeklySlots.length === 0) { + return null; + } + + const dayNames = { + 1: 'Segunda', + 2: 'Terça', + 3: 'Quarta', + 4: 'Quinta', + 5: 'Sexta', + }; + + let count = weeksCount; + let untilDate = null; + + if (recurrenceMode === 'until_date' && repeatUntil) { + const until = new Date(`${repeatUntil}T23:59:59`); + const now = new Date(); + const diffDays = Math.max( + 0, + Math.floor( + (until.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + ), + ); + count = Math.min(26, Math.max(1, Math.floor(diffDays / 7) + 1)); + untilDate = repeatUntil; + } + + // Don't show summary if in until_date mode but no date is selected + if (recurrenceMode === 'until_date' && !repeatUntil) { + return null; + } + + const slotDescriptions = weeklySlots.map((slot) => { + const dayName = + dayNames[slot.day_of_week as keyof typeof dayNames] || 'Dia'; + + return `${dayName} das ${slot.start_time} às ${slot.end_time}`; + }); + + return { + slots: slotDescriptions, + weeks: count, + untilDate, + }; + })(); + + // Filter by search + const searchFilteredAssets = assets.filter( + (asset) => + asset.name.toLowerCase().includes(search.toLowerCase()) || + (asset.model_name && + asset.model_name + .toLowerCase() + .includes(search.toLowerCase())) || + asset.asset_tag.toLowerCase().includes(search.toLowerCase()) || + (asset.category_name && + asset.category_name + .toLowerCase() + .includes(search.toLowerCase())), + ); + + // When recurring or using slot selector, also filter by availability (filteredAssets state) + // When not recurring and not using slot selector, just use search-filtered assets + const currentFilteredAssets = isRecurring || useSlotSelector + ? searchFilteredAssets.filter((asset) => + filteredAssets.some((a) => a.id === asset.id), + ) + : searchFilteredAssets; + + // Group assets by description (category + model) + const groupedAssets = currentFilteredAssets.reduce( + (groups, asset) => { + const description = `${asset.category_name} · ${asset.model_name}`; + + if (!groups[description]) { + groups[description] = []; + } + + groups[description].push(asset); + + return groups; + }, + {} as Record, + ); + + const sortedGroups = Object.entries(groupedAssets).sort((a, b) => { + // Sort groups with multiple items first + if (a[1].length > 1 && b[1].length === 1) { + return -1; + } + + if (a[1].length === 1 && b[1].length > 1) { + return 1; + } + + // Then by description + return a[0].localeCompare(b[0]); + }); + + function toggleGroup(description: string) { + const groupAssets = groupedAssets[description]; + const allSelected = groupAssets.every((asset) => + form.data.asset_ids.includes(asset.id), + ); + + let newAssetIds = [...form.data.asset_ids]; + + if (allSelected) { + // Deselect all + newAssetIds = newAssetIds.filter( + (id) => !groupAssets.some((asset) => asset.id === id), + ); + } else { + // Select all + groupAssets.forEach((asset) => { + if (!newAssetIds.includes(asset.id)) { + newAssetIds.push(asset.id); + } + }); + } + + form.setData('asset_ids', newAssetIds); + } + + function toggleGroupExpansion(description: string) { + setExpandedGroups((prev) => { + const newSet = new Set(prev); + + if (newSet.has(description)) { + newSet.delete(description); + } else { + newSet.add(description); + } + + return newSet; + }); + } + + async function submit(event: FormEvent) { event.preventDefault(); - form.post('/requisicoes'); + + if (!isRecurring) { + if (useSlotSelector) { + // Submit multiple reservations + if (weeklySlots.length === 0) { + setGeneralError('Selecione pelo menos um horário.'); + return; + } + + // Limpar erros anteriores antes de enviar + form.clearErrors(); + setGeneralError(''); + + const xsrfToken = (() => { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + + return match ? decodeURIComponent(match[1]) : ''; + })(); + + const response = await fetch('/requisicoes/multiplas', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + }, + body: JSON.stringify({ + weekly_slots: weeklySlots, + week_offset: nonRecurringWeekOffset, + purpose: form.data.purpose, + usage_location: form.data.usage_location, + asset_ids: form.data.asset_ids, + terms_accepted: form.data.terms_accepted, + }), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => null); + + // Se for erro de validação (422), mostrar erros por campo + if (response.status === 422 && errData?.errors) { + const fieldErrors = errData.errors as Record; + const knownFields = ['purpose', 'usage_location', 'asset_ids', 'terms_accepted'] as const; + const mappedErrors: Partial> = {}; + let hasFieldError = false; + + for (const field of knownFields) { + if (fieldErrors[field]?.length) { + mappedErrors[field] = fieldErrors[field][0]; + hasFieldError = true; + } + } + + if (hasFieldError) { + form.setError(mappedErrors); + return; + } + } + + setGeneralError( + errData?.message || 'Erro ao criar requisições.', + ); + return; + } + + router.get('/dashboard'); + + return; + } + + form.post('/requisicoes', { + preserveState: true, + preserveScroll: true, + }); + + return; + } + + // Validate basic fields before launching modal + if (weeklySlots.length === 0) { + setGeneralError('Adicione pelo menos um horário semanal.'); + return; + } + + // Clear previous errors + form.clearErrors(); + setGeneralError(''); + + setShowConflictModal(true); + setIsCheckingRecurrence(true); + setCheckResult(null); + + try { + const xsrfToken = (() => { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + + return match ? decodeURIComponent(match[1]) : ''; + })(); + + const response = await fetch('/requisicoes/verificar-recorrencia', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + }, + body: JSON.stringify({ + weekly_slots: weeklySlots, + repeat_until: + recurrenceMode === 'until_date' ? repeatUntil : null, + weeks_count: + recurrenceMode === 'weeks_count' ? weeksCount : null, + asset_ids: form.data.asset_ids, + }), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => null); + + throw new Error( + errData?.message || + 'Erro ao verificar disponibilidade da série.', + ); + } + + const data = await response.json(); + setCheckResult(data); + } catch (err) { + console.error(err); + setShowConflictModal(false); + setGeneralError( + err instanceof Error + ? err.message + : 'Erro ao verificar repetição semanal.', + ); + } finally { + setIsCheckingRecurrence(false); + } + } + + function handleConfirmSeries( + occurrencesToSubmit: Array<{ + starts_at: string; + ends_at: string; + asset_ids: number[]; + }>, + ) { + setIsSubmittingSeries(true); + router.post( + '/requisicoes/recorrente', + { + purpose: form.data.purpose, + usage_location: form.data.usage_location, + terms_accepted: form.data.terms_accepted, + occurrences: occurrencesToSubmit, + }, + { + preserveState: true, + preserveScroll: true, + onFinish: () => { + setIsSubmittingSeries(false); + setShowConflictModal(false); + }, + }, + ); } function toggleAsset(id: number, checked: boolean) { form.setData( @@ -53,6 +426,28 @@ export default function CreateReservation({ ? [...form.data.asset_ids, id] : form.data.asset_ids.filter((item) => item !== id), ); + + setGeneralError(''); + + // Check slot availability when using slot selector + if (useSlotSelector || isRecurring) { + checkSlotAvailability(); + } + } + + // Update availability when week offset changes + function handleWeekOffsetChange(newOffset: number) { + if (useSlotSelector) { + setNonRecurringWeekOffset(newOffset); + } else { + setWeekOffset(newOffset); + } + + if (useSlotSelector || isRecurring) { + refreshWeeklyAvailability(); + } else { + checkSlotAvailability(); + } } function refreshAvailability() { router.get('/requisicoes/nova', { @@ -61,10 +456,115 @@ export default function CreateReservation({ }); } + async function refreshWeeklyAvailability() { + if (weeklySlots.length === 0) { + setFilteredAssets([]); + setSlotAvailability(new Map()); + + return; + } + + try { + const xsrfToken = (() => { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + + return match ? decodeURIComponent(match[1]) : ''; + })(); + + const weekOffset = isRecurring ? 0 : nonRecurringWeekOffset; + + const response = await fetch( + '/requisicoes/verificar-disponibilidade-semanal', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + }, + body: JSON.stringify({ + weekly_slots: weeklySlots, + week_offset: weekOffset, + }), + }, + ); + + if (!response.ok) { + const errData = await response.json().catch(() => null); + + throw new Error( + errData?.message || + 'Erro ao verificar disponibilidade semanal.', + ); + } + + const data = await response.json(); + setFilteredAssets(data.assets); + } catch (err) { + console.error(err); + setGeneralError( + err instanceof Error + ? err.message + : 'Erro ao verificar disponibilidade semanal.', + ); + } + } + + async function checkSlotAvailability() { + if (form.data.asset_ids.length === 0) { + setSlotAvailability(new Map()); + + return; + } + + try { + const xsrfToken = (() => { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + + return match ? decodeURIComponent(match[1]) : ''; + })(); + + const weekOffset = isRecurring ? 0 : nonRecurringWeekOffset; + + const response = await fetch( + '/requisicoes/verificar-disponibilidade-slots', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + }, + body: JSON.stringify({ + weekly_slots: weeklySlots, + asset_ids: form.data.asset_ids, + week_offset: weekOffset, + }), + }, + ); + + if (!response.ok) { + const errData = await response.json().catch(() => null); + + throw new Error( + errData?.message || + 'Erro ao verificar disponibilidade de slots.', + ); + } + + const data = await response.json(); + setSlotAvailability(new Map(Object.entries(data.availability))); + } catch (err) { + console.error(err); + // Don't show alert for this one, just clear availability + setSlotAvailability(new Map()); + } + } + return ( <> -
+
} /> -

- Regras atuais: antecedência mínima de{' '} - {policy.minNoticeHours} h, duração máxima de{' '} - {policy.maxDurationHours} h, até {policy.maxAssets}{' '} - equipamentos - {policy.turnaroundMinutes > 0 - ? ` e margem operacional de ${policy.turnaroundMinutes} min.` - : '.'} -

+ + + +
+

+ Regras atuais +

+

+ Antecedência mínima de {policy.minNoticeHours}{' '} + h, duração máxima de {policy.maxDurationHours}{' '} + h, até {policy.maxAssets} equipamentos + {policy.turnaroundMinutes > 0 + ? ` e margem operacional de ${policy.turnaroundMinutes} min.` + : '.'} +

+
+
+
+ {generalError && ( +
+ {generalError} +
+ )}
@@ -99,79 +614,731 @@ export default function CreateReservation({ - - - form.setData( - 'starts_at', - e.target.value, - ) - } - /> - - - - form.setData( - 'ends_at', - e.target.value, - ) - } - /> - -
- - - form.setData( - 'usage_location', - e.target.value, - ) - } - placeholder="Ex.: Auditório principal" + {!isRecurring && ( + <> +
+ { + setUseSlotSelector( + !val, + ); + + if (!val) { + setWeeklySlots([]); + setSlotAvailability( + new Map(), + ); + setFilteredAssets(assets); + } else { + setFilteredAssets([]); + } + }} + /> + +
+ + )} + + {!isRecurring && !useSlotSelector && ( + <> + + { + const val = e.target.value; + form.setData( + 'starts_at', + val, + ); + form.clearErrors('starts_at'); + + if (!repeatUntil && val) { + try { + const date = + new Date(val); + + if ( + !isNaN( + date.getTime(), + ) + ) { + date.setDate( + date.getDate() + + 28, + ); + // Use local timezone instead of UTC + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + setRepeatUntil( + `${year}-${month}-${day}`, + ); + } + } catch { + // ignore + } + } + }} + className="h-11" + /> + + + { + form.setData( + 'ends_at', + e.target.value, + ); + form.clearErrors('ends_at'); + }} + className="h-11" + /> + + + )} + + {!isRecurring && useSlotSelector && ( +
+
+ +
+ + + {getWeekDateRange( + nonRecurringWeekOffset, + )} + + +
+
+ - +
+ )} +
+
+
+
+ { + setIsRecurring( + Boolean(val), + ); + + if (val) { + form.setData( + 'starts_at', + '', + ); + form.setData( + 'ends_at', + '', + ); + setFilteredAssets([]); + setUseSlotSelector(true); + setUseRecurringSlotSelector( + true, + ); + } else { + setFilteredAssets( + useSlotSelector + ? [] + : assets, + ); + } + }} + /> + +
+

+ Defina múltiplos horários + semanais para repetir durante + várias semanas (ex.: aulas em + dias diferentes). +

+
+
+ + {isRecurring && ( +
+
+ { + setUseRecurringSlotSelector( + !val, + ); + + if (!val) { + setWeeklySlots([]); + setFilteredAssets([]); + } + }} + /> + +
+ +
+ + +
+ + {recurrenceMode === 'until_date' ? ( +
+ + + setRepeatUntil( + e.target.value, + ) + } + className="mt-1.5 h-10 max-w-xs" + /> +

+ Ex.: término do semestre + ou período letivo. +

+
+ ) : ( +
+ +
+ + setWeeksCount( + Math.max( + 1, + parseInt( + e + .target + .value, + 10, + ) || 1, + ), + ) + } + className="h-10 w-24" + /> +
+ {[2, 4, 8, 12].map( + (num) => ( + + ), + )} +
+
+
+ )} + + {!useRecurringSlotSelector && ( +
+
+ +
+
+ {[ + { value: 1, label: 'Segunda' }, + { value: 2, label: 'Terça' }, + { value: 3, label: 'Quarta' }, + { value: 4, label: 'Quinta' }, + { value: 5, label: 'Sexta' }, + ].map((day) => ( + + ))} +
+ +
+
+ +
+ + + {getWeekDateRange( + weekOffset, + )} + + +
+
+ +
+ + { + form.setData( + 'starts_at', + e.target + .value, + ); + form.clearErrors('starts_at'); + }} + className="h-11" + /> + + + { + form.setData( + 'ends_at', + e.target + .value, + ); + form.clearErrors('ends_at'); + }} + className="h-11" + /> + +
+ + +
+ + {weeklySlots.length > 0 && ( +
+ +
+ {weeklySlots.map( + ( + slot, + index, + ) => ( +
+ + { + [ + '', + 'Segunda', + 'Terça', + 'Quarta', + 'Quinta', + 'Sexta', + ][ + slot.day_of_week + ] + }{' '} + das{' '} + { + slot.start_time + }{' '} + às{' '} + { + slot.end_time + } + + +
+ ), + )} +
+
+ )} +
+ )} + + {useRecurringSlotSelector && ( + + )} + + {recurringSummary && ( +
+ +
+

+ 📅{' '} + {recurringSummary.slots.join( + ', ', + )} +

+ {recurrenceMode === + 'until_date' ? ( +

+ Até ao dia{' '} + + { + recurringSummary.untilDate + } + + . +

+ ) : ( +

+ Num total de{' '} + + { + recurringSummary.weeks + }{' '} + semanas + + . +

+ )} +
+
+ )} +
+ )}
- -

- Confirme a disponibilidade sempre que - alterar o período. -

+
+ +
+ { + form.setData( + 'usage_location', + e.target.value, + ); + + setGeneralError(''); + }} + placeholder="Ex.: Auditório principal" + className="h-11" + /> +
+ +
+ {(isRecurring || useSlotSelector) && ( +
+ +

+ Clique para verificar quais equipamentos + estão disponíveis para os horários + selecionados. +

+
+ )} + {!isRecurring && !useSlotSelector && ( +
+ +

+ Confirme a disponibilidade sempre + que alterar o período. +

+
+ )}