diff --git a/CHANGELOG.md b/CHANGELOG.md index e388390..222d7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Sincronização horária e manual de equipamentos, job único, lock distribuído, histórico de execuções e desativação segura de ausentes. - Área administrativa de diagnóstico Snipe-IT com teste de conectividade, métricas do cache e histórico de sincronizações. - Autorização administrativa através de Gate e navegação condicional por perfil. +- Pesquisa de disponibilidade por período no catálogo e formulário, incluindo reservas, atribuições atuais e bloqueios administrativos. +- Cancelamento pelo requisitante com validação da máquina de estados, libertação transacional dos equipamentos e auditoria. +- Expiração automática e manual de pré-reservas, com job único agendado a cada cinco minutos e comando `reservations:expire`. +- Calendário mensal pessoal com isolamento por utilizador e navegação entre meses. ### Alterado @@ -26,6 +30,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Configuração de desenvolvimento Vite passa a publicar módulos e HMR em `localhost:5173` e a aceitar explicitamente a origem do portal em `localhost:8000`, corrigindo a página em branco em Docker. - As mensagens flash globais recebem agora as propriedades diretamente de `withApp`, evitando o uso de `usePage()` fora do contexto Inertia que interrompia o arranque do React. - SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite. +- Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo. ### Segurança @@ -35,9 +40,11 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue ### Verificado - Migrations e seeders executados em PostgreSQL 17 real. -- Suite backend: Pint, PHPStan nível 7 e 53 testes Pest (201 asserções). +- Suite backend: Pint em 102 ficheiros, PHPStan nível 7 em 77 ficheiros e 60 testes Pest (270 asserções). - Frontend: TypeScript, ESLint, Prettier e build Vite de produção. - Stack Docker operacional e endpoint de login acessível através do Nginx. - Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”. - API Snipe-IT simulada em testes: paginação, headers, retry, erro em HTTP 200, falha parcial, idempotência e desativação de ausentes. - Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17. +- Comando de expiração executado em PostgreSQL e agendamento a cada cinco minutos confirmado. +- Builds Vite cliente e SSR com as páginas de disponibilidade, cancelamento e calendário concluídos. diff --git a/README.md b/README.md index 29601b8..3db276c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame ## Estado atual -O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e o primeiro fluxo vertical da Fase 3. Estão disponíveis autenticação local, perfis base, catálogo sincronizável, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, histórico pessoal, dashboard e diagnóstico administrativo da integração. A próxima entrega completa o motor de reservas com pesquisa temporal, cancelamento e expiração. +O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e a **Fase 3 — Motor de reservas**. Estão disponíveis autenticação local, perfis base, catálogo sincronizável com pesquisa temporal, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, cancelamento auditado, expiração automática, calendário e histórico pessoal, dashboard e diagnóstico administrativo da integração. A próxima entrega implementa o fluxo de aprovação. Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada. diff --git a/app/Console/Commands/ExpirePendingReservationsCommand.php b/app/Console/Commands/ExpirePendingReservationsCommand.php new file mode 100644 index 0000000..e3d8e2d --- /dev/null +++ b/app/Console/Commands/ExpirePendingReservationsCommand.php @@ -0,0 +1,29 @@ +option('queue')) { + ExpirePendingReservations::dispatch(); + $this->info('Expiração de pré-reservas colocada na fila.'); + + return self::SUCCESS; + } + + $expired = $reservations->expirePending(); + $this->info("{$expired} pré-reserva(s) expirada(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Domain/Reservations/Models/Reservation.php b/app/Domain/Reservations/Models/Reservation.php index 7bd2a4c..176e2ee 100644 --- a/app/Domain/Reservations/Models/Reservation.php +++ b/app/Domain/Reservations/Models/Reservation.php @@ -4,11 +4,18 @@ namespace App\Domain\Reservations\Models; use App\Domain\Reservations\Enums\ReservationStatus; use App\Models\User; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property ReservationStatus $status + * @property CarbonImmutable $starts_at + * @property CarbonImmutable $ends_at + * @property CarbonImmutable|null $approval_deadline + */ class Reservation extends Model { use HasUuids; diff --git a/app/Domain/Reservations/Services/AvailabilityService.php b/app/Domain/Reservations/Services/AvailabilityService.php index 459de88..2bf72b5 100644 --- a/app/Domain/Reservations/Services/AvailabilityService.php +++ b/app/Domain/Reservations/Services/AvailabilityService.php @@ -5,9 +5,26 @@ namespace App\Domain\Reservations\Services; use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Enums\ReservationItemStatus; use DateTimeInterface; +use Illuminate\Database\Eloquent\Builder; class AvailabilityService { + /** @return Builder */ + public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder + { + return SnipeAsset::query() + ->where('active', true) + ->where('requestable', true) + ->whereNull('assigned_id') + ->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query + ->whereIn('status', ReservationItemStatus::blockingValues()) + ->where('starts_at', '<', $endsAt) + ->where('ends_at', '>', $startsAt)) + ->whereDoesntHave('blocks', fn (Builder $query): Builder => $query + ->where('starts_at', '<', $endsAt) + ->where('ends_at', '>', $startsAt)); + } + public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool { if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) { diff --git a/app/Domain/Reservations/Services/ReservationService.php b/app/Domain/Reservations/Services/ReservationService.php index d4c421c..c522cff 100644 --- a/app/Domain/Reservations/Services/ReservationService.php +++ b/app/Domain/Reservations/Services/ReservationService.php @@ -9,6 +9,7 @@ use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Models\Reservation; use App\Models\User; use Carbon\CarbonImmutable; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -78,6 +79,107 @@ class ReservationService }, 3); } + public function cancel(Reservation $reservation, User $actor, string $reason): Reservation + { + if ($reservation->requester_id !== $actor->id) { + throw new AuthorizationException('Apenas o requisitante pode cancelar este pedido.'); + } + + return DB::transaction(function () use ($reservation, $actor, $reason): Reservation { + $lockedReservation = Reservation::query() + ->whereKey($reservation->id) + ->lockForUpdate() + ->firstOrFail(); + + if (! $lockedReservation->status->canTransitionTo(ReservationStatus::Cancelled)) { + throw ValidationException::withMessages([ + 'reason' => 'Esta requisição já não pode ser cancelada.', + ]); + } + + $previousStatus = $lockedReservation->status->value; + $lockedReservation->update([ + 'status' => ReservationStatus::Cancelled, + 'cancelled_by' => $actor->id, + 'cancelled_at' => now(), + 'cancellation_reason' => $reason, + ]); + $lockedReservation->items() + ->whereIn('status', ReservationItemStatus::blockingValues()) + ->update(['status' => ReservationItemStatus::Cancelled]); + + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'reservation.cancelled', + 'auditable_type' => $lockedReservation->getMorphClass(), + 'auditable_id' => $lockedReservation->id, + 'before' => ['status' => $previousStatus], + 'after' => [ + 'status' => ReservationStatus::Cancelled->value, + 'cancellation_reason' => $reason, + ], + ]); + + return $lockedReservation->load('items.asset'); + }, 3); + } + + public function expirePending(int $limit = 100): int + { + $reservationIds = Reservation::query() + ->whereIn('status', [ + ReservationStatus::PendingApproval, + ReservationStatus::AwaitingInformation, + ]) + ->whereNotNull('approval_deadline') + ->where('approval_deadline', '<=', now()) + ->oldest('approval_deadline') + ->limit($limit) + ->pluck('id'); + + $expired = 0; + + foreach ($reservationIds as $reservationId) { + $didExpire = DB::transaction(function () use ($reservationId): bool { + $reservation = Reservation::query() + ->whereKey($reservationId) + ->lockForUpdate() + ->first(); + + if ($reservation === null + || $reservation->approval_deadline === null + || $reservation->approval_deadline->isFuture() + || ! $reservation->status->canTransitionTo(ReservationStatus::Expired)) { + return false; + } + + $previousStatus = $reservation->status->value; + $reservation->update(['status' => ReservationStatus::Expired]); + $reservation->items() + ->whereIn('status', ReservationItemStatus::blockingValues()) + ->update(['status' => ReservationItemStatus::Cancelled]); + + AuditLog::create([ + 'actor_id' => null, + 'event' => 'reservation.expired', + 'auditable_type' => $reservation->getMorphClass(), + 'auditable_id' => $reservation->id, + 'before' => ['status' => $previousStatus], + 'after' => ['status' => ReservationStatus::Expired->value], + 'metadata' => ['approval_deadline' => $reservation->approval_deadline->toIso8601String()], + ]); + + return true; + }, 3); + + if ($didExpire) { + $expired++; + } + } + + return $expired; + } + private function nextReference(): string { do { diff --git a/app/Http/Controllers/AssetController.php b/app/Http/Controllers/AssetController.php index 3f132b7..7ac964d 100644 --- a/app/Http/Controllers/AssetController.php +++ b/app/Http/Controllers/AssetController.php @@ -3,23 +3,36 @@ namespace App\Http\Controllers; use App\Domain\Inventory\Models\SnipeAsset; +use App\Domain\Reservations\Services\AvailabilityService; +use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; class AssetController extends Controller { - public function index(Request $request): Response + public function index(Request $request, AvailabilityService $availability): Response { $filters = $request->validate([ 'search' => ['nullable', 'string', 'max:100'], 'category' => ['nullable', 'string', 'max:100'], 'location' => ['nullable', 'string', 'max:100'], + 'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'], + 'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'], ]); - $assets = SnipeAsset::query() - ->where('active', true) - ->where('requestable', true) + $periodSelected = isset($filters['starts_at'], $filters['ends_at']); + $query = $periodSelected + ? $availability->availableAssets( + CarbonImmutable::parse($filters['starts_at']), + CarbonImmutable::parse($filters['ends_at']), + ) + : SnipeAsset::query() + ->where('active', true) + ->where('requestable', true) + ->whereNull('assigned_id'); + + $assets = $query ->when($filters['search'] ?? null, function ($query, string $search) { $query->where(function ($query) use ($search) { $query->where('name', 'like', "%{$search}%") @@ -36,8 +49,9 @@ class AssetController extends Controller return Inertia::render('assets/index', [ 'assets' => $assets, 'filters' => $filters, - 'categories' => SnipeAsset::query()->where('active', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'), - 'locations' => SnipeAsset::query()->where('active', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'), + 'periodSelected' => $periodSelected, + 'categories' => SnipeAsset::query()->where('active', true)->where('requestable', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'), + 'locations' => SnipeAsset::query()->where('active', true)->where('requestable', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'), 'lastSyncedAt' => SnipeAsset::max('synced_at'), ]); } diff --git a/app/Http/Controllers/ReservationController.php b/app/Http/Controllers/ReservationController.php index 9ea189e..d45b6d6 100644 --- a/app/Http/Controllers/ReservationController.php +++ b/app/Http/Controllers/ReservationController.php @@ -3,8 +3,11 @@ namespace App\Http\Controllers; 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\StoreReservationRequest; +use Carbon\CarbonImmutable; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -22,16 +25,54 @@ class ReservationController extends Controller ]); } - public function create(Request $request): Response + public function calendar(Request $request): Response { - return Inertia::render('reservations/create', [ - 'assets' => SnipeAsset::query() + $filters = $request->validate([ + 'month' => ['nullable', 'date_format:Y-m'], + ]); + $month = isset($filters['month']) + ? CarbonImmutable::createFromFormat('!Y-m', $filters['month']) + : now()->startOfMonth(); + $windowEnd = $month->addMonth(); + + 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(), + ]); + } + + public function create(Request $request, AvailabilityService $availability): Response + { + $period = $request->validate([ + 'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'], + 'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'], + ]); + $periodSelected = isset($period['starts_at'], $period['ends_at']); + $query = $periodSelected + ? $availability->availableAssets( + CarbonImmutable::parse($period['starts_at']), + CarbonImmutable::parse($period['ends_at']), + ) + : SnipeAsset::query() ->where('active', true) ->where('requestable', true) - ->whereNull('assigned_id') - ->orderBy('name') - ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']), - 'selectedAssetId' => $request->integer('asset'), + ->whereNull('assigned_id'); + + $assets = $query + ->orderBy('name') + ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']); + $requestedAssetId = $request->integer('asset'); + + return Inertia::render('reservations/create', [ + 'assets' => $assets, + 'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0, + 'period' => $period, + 'periodSelected' => $periodSelected, ]); } @@ -43,4 +84,15 @@ class ReservationController extends Controller return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação."); } + + public function cancel(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse + { + $data = $request->validate([ + 'reason' => ['required', 'string', 'min:5', 'max:1000'], + ]); + + $service->cancel($reservation, $request->user(), $data['reason']); + + return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis."); + } } diff --git a/app/Jobs/ExpirePendingReservations.php b/app/Jobs/ExpirePendingReservations.php new file mode 100644 index 0000000..18bbacb --- /dev/null +++ b/app/Jobs/ExpirePendingReservations.php @@ -0,0 +1,32 @@ + */ + public array $backoff = [10, 30, 60]; + + public function uniqueId(): string + { + return 'pending-reservations'; + } + + public function handle(ReservationService $reservations): void + { + $reservations->expirePending(); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d5322b5..f896344 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,6 +48,10 @@ A defesa ocorre em duas camadas: Uma criação ou aprovação deve bloquear as linhas dos ativos, voltar a validar dentro da transação e tolerar corrida através da restrição da base de dados. +`AvailabilityService::availableAssets()` aplica as mesmas condições em SQL para alimentar catálogo e formulário sem carregar todo o inventário em memória. O período consultado é propagado até à criação da requisição; a validação transacional continua a ser a garantia aplicacional final. + +Pré-reservas ficam bloqueantes em `held` enquanto aguardam decisão. `ReservationService` centraliza criação, cancelamento e expiração: todas as transições bloqueiam a requisição, atualizam os itens bloqueantes e criam um `AuditLog`. Um job único procura prazos vencidos a cada cinco minutos. A restrição GiST continua a proteger contra corridas entre estes ciclos de vida. + ## Fluxo de dados Snipe-IT 1. Um job consulta a API paginada com token de leitura. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 821d7d2..3f13195 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2,7 +2,7 @@ ## ADR-001 — Monólito modular Laravel/Inertia -**Data:** 2026-08-04 +**Data:** 2026-08-04 **Estado:** aceite Usar Laravel 13 com React/TypeScript através de Inertia no mesmo projeto. Mantém autenticação, autorização, filas e transações numa única unidade operacional, sem impedir separação por domínio. @@ -48,3 +48,10 @@ Textos funcionais são escritos em português europeu. Nomes de código, eventos **Estado:** aceite Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis até ao máximo oficial de 500 registos. Cada ativo visto recebe o ID da execução em `last_seen_sync_run_id`; a desativação de ausentes só ocorre depois de todas as páginas terminarem com sucesso. Upserts de páginas já válidas podem permanecer após uma falha, permitindo convergência na execução seguinte sem manter uma transação aberta durante chamadas HTTP. Repetir apenas falhas de ligação, HTTP 429 e HTTP 5xx. Usar lock distribuído, job único e token exclusivo de leitura. + +## ADR-008 — Pré-reserva bloqueante com expiração auditada + +**Data:** 2026-08-04 +**Estado:** aceite + +Uma requisição submetida cria itens em `held` e bloqueia o período durante 48 horas, até decisão, cancelamento ou expiração. O próprio requisitante pode cancelar apenas enquanto a máquina de estados admite a transição. Um job único executado a cada cinco minutos expira pedidos `pending_approval` ou `awaiting_information` com prazo vencido. Cancelamento e expiração usam bloqueio transacional da requisição, libertam todos os itens bloqueantes e produzem eventos de auditoria; intervalos que se tocam continuam permitidos pela semântica `[início, fim)`. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 1bc4bfb..2b4a8d1 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,7 +1,7 @@ # Estado do projeto e handoff **Atualizado em:** 4 de agosto de 2026 -**Fase:** Fases 1 e 2 concluídas; primeiro fluxo vertical da Fase 3 concluído +**Fase:** Fases 1, 2 e 3 concluídas **Último agente:** Codex ## Resultado disponível @@ -21,26 +21,31 @@ - Cliente Snipe-IT autenticado com paginação, timeout, retry seletivo e validação de respostas. - Sincronização convergente de ativos com histórico, hash de conteúdo, lock, job único e desativação apenas após execução completa. - Comando `snipe:sync-assets`, execução horária e painel de diagnóstico exclusivo para administradores. +- Pesquisa do catálogo por início/fim que exclui atribuições atuais, reservas bloqueantes e bloqueios administrativos. +- Propagação do período consultado do catálogo para o formulário e consulta atualizável de equipamentos livres. +- Cancelamento pelo próprio requisitante, com validação de estado, libertação transacional dos itens e auditoria. +- Expiração de pré-reservas vencidas através do comando `reservations:expire` e job único agendado a cada cinco minutos. +- Calendário mensal pessoal, isolado por utilizador e com navegação entre meses. ## Limites conhecidos - A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder. - Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas. - Aprovação, rejeição, entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface. -- As notificações e jobs de expiração ainda não foram implementados. +- As notificações ainda não foram implementadas. - As políticas de autorização finas ainda não foram ligadas às rotas; nesta fase todas as rotas funcionais exigem utilizador autenticado, verificado e ativo. -- A disponibilidade apresentada no catálogo ainda não recebe um período; a validação real ocorre ao submeter. +- O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa. ## Próxima tarefa recomendada -Completar a **Fase 3 — motor de reservas**: +Implementar a **Fase 4 — aprovações**: -1. abrir `app/Domain/Reservations/Services/AvailabilityService.php` e `ReservationService.php`; -2. acrescentar pesquisa do catálogo por período e apresentar disponibilidade real; -3. implementar cancelamento pelo requisitante com transição explícita e auditoria; -4. criar expiração automática de pré-reservas pendentes e libertação dos itens; -5. adicionar uma vista de calendário pessoal; -6. testar limites temporais, cancelamento, expiração e autorização; +1. criar `app/Domain/Approvals/Services/ApprovalService.php` e policies para o perfil aprovador; +2. criar fila e detalhe de requisições pendentes com filtros por data, categoria e requisitante; +3. implementar aprovação com nova validação transacional de disponibilidade e promoção dos itens para `reserved`; +4. implementar rejeição e pedido de esclarecimentos, com justificação e registo em `approval_actions`/auditoria; +5. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos; +6. emitir notificações assíncronas ao requisitante e ao aprovador; 7. atualizar este documento e o changelog. ## Testes da última sessão @@ -48,19 +53,20 @@ Completar a **Fase 3 — motor de reservas**: Validação concluída em 4 de agosto de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 97 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 75 ficheiros, zero erros; -- Pest: 53 testes passaram, 201 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 102 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 77 ficheiros, zero erros; +- Pest: 60 testes passaram, 270 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, 2305 módulos cliente transformados; +- Vite: builds cliente e SSR concluídos, 2306 módulos cliente e 95 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; - 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 continua impedida por uma política administrativa temporariamente indisponível; o painel administrativo foi validado por teste Inertia, TypeScript e builds cliente/SSR. +- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; catálogo, cancelamento e calendário foram validados por testes Inertia, TypeScript e builds cliente/SSR. ## Decisões pendentes do contexto institucional diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 079ce69..d2c1669 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -80,6 +80,23 @@ docker compose exec app php artisan schedule:list Administradores podem testar a ligação, iniciar uma execução e consultar as últimas dez sincronizações em `http://localhost:8000/administracao/snipe-it`. Uma execução falhada mantém o último catálogo válido e não desativa registos ausentes. Consultar `sync_runs`, os logs do worker e o painel antes de repetir. +## Ciclo de vida das pré-reservas + +As requisições pendentes têm, por defeito, um prazo de decisão de 48 horas. O scheduler coloca a expiração na fila a cada cinco minutos. Para operar ou diagnosticar manualmente: + +```bash +# Expirar imediatamente os pedidos vencidos +docker compose exec app php artisan reservations:expire + +# Colocar a operação na fila +docker compose exec app php artisan reservations:expire --queue + +# Confirmar ambos os agendamentos +docker compose exec app php artisan schedule:list +``` + +Uma expiração ou cancelamento muda itens `held`, `reserved`, `ready` e `delivered` para `cancelled`, libertando o período. Confirmar os eventos `reservation.expired` ou `reservation.cancelled` em `audit_logs` quando existir uma divergência operacional. + ## Produção Antes de disponibilizar: diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index c7db145..b35c53b 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { Link, usePage } from '@inertiajs/react'; import { CalendarDays, + CalendarRange, LayoutGrid, PackageSearch, PlusCircle, @@ -35,6 +36,11 @@ const mainNavItems: NavItem[] = [ href: '/requisicoes', icon: CalendarDays, }, + { + title: 'O meu calendário', + href: '/requisicoes/calendario', + icon: CalendarRange, + }, ]; const footerNavItems: NavItem[] = [ diff --git a/resources/js/pages/assets/index.tsx b/resources/js/pages/assets/index.tsx index b650659..40bccdd 100644 --- a/resources/js/pages/assets/index.tsx +++ b/resources/js/pages/assets/index.tsx @@ -1,6 +1,7 @@ import { Head, Link, router } from '@inertiajs/react'; import { Boxes, + CalendarClock, MapPin, PackageCheck, Search, @@ -24,7 +25,14 @@ import type { Asset, Paginated } from '@/types'; type Props = { assets: Paginated; - filters: { search?: string; category?: string; location?: string }; + filters: { + search?: string; + category?: string; + location?: string; + starts_at?: string; + ends_at?: string; + }; + periodSelected: boolean; categories: string[]; locations: string[]; lastSyncedAt: string | null; @@ -36,10 +44,13 @@ export default function AssetsIndex({ categories, locations, lastSyncedAt, + periodSelected, }: Props) { const [search, setSearch] = useState(filters.search ?? ''); const [category, setCategory] = useState(filters.category ?? 'all'); const [location, setLocation] = useState(filters.location ?? 'all'); + const [startsAt, setStartsAt] = useState(filters.starts_at ?? ''); + const [endsAt, setEndsAt] = useState(filters.ends_at ?? ''); function submit(event: FormEvent) { event.preventDefault(); @@ -49,6 +60,8 @@ export default function AssetsIndex({ search: search || undefined, category: category === 'all' ? undefined : category, location: location === 'all' ? undefined : location, + starts_at: startsAt || undefined, + ends_at: endsAt || undefined, }, { preserveState: true }, ); @@ -66,9 +79,9 @@ export default function AssetsIndex({
-
+
+
+ + + setStartsAt(event.target.value) + } + className="pl-9" + required={Boolean(endsAt)} + /> +
+ setEndsAt(event.target.value)} + required={Boolean(startsAt)} + />