feat: add transactional approval workflow

This commit is contained in:
2026-08-04 16:36:15 +01:00
parent e667d05bc1
commit c2136bc250
18 changed files with 620 additions and 20 deletions

View File

@@ -22,6 +22,8 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- 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.
- Núcleo de aprovações com fila pesquisável, Gate por perfil, aprovação, rejeição e pedido de esclarecimentos.
- Revalidação transacional da disponibilidade antes da aprovação, promoção de itens para `reserved` e histórico em `approval_actions`/auditoria.
### Alterado
@@ -40,7 +42,7 @@ 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 em 102 ficheiros, PHPStan nível 7 em 77 ficheiros e 60 testes Pest (270 asserções).
- Suite backend: Pint em 106 ficheiros, PHPStan nível 7 em 80 ficheiros e 65 testes Pest (289 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”.

View File

@@ -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 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.
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**. A **Fase 4 — Aprovações** está em curso, já com fila protegida por perfil e decisões transacionais. Estão também disponíveis autenticação local, catálogo sincronizável com pesquisa temporal, pré-reservas, conflitos, cancelamento auditado, expiração automática, calendário pessoal, dashboard e diagnóstico administrativo da integraçã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.

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Approvals\Models;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ApprovalAction extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'reservation_id', 'actor_id', 'action', 'previous_status', 'new_status',
'comment', 'created_at',
];
/** @return BelongsTo<Reservation, $this> */
public function reservation(): BelongsTo
{
return $this->belongsTo(Reservation::class);
}
/** @return BelongsTo<User, $this> */
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Domain\Approvals\Services;
use App\Domain\Approvals\Models\ApprovalAction;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Services\AvailabilityService;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ApprovalService
{
public function __construct(private readonly AvailabilityService $availability) {}
public function approve(Reservation $reservation, User $actor, ?string $comment = null): Reservation
{
$this->authorize($actor);
return DB::transaction(function () use ($reservation, $actor, $comment): Reservation {
$locked = $this->lockPending($reservation);
$items = $locked->items()->with('asset')->get();
SnipeAsset::query()->whereKey($items->pluck('snipe_asset_id'))->orderBy('id')->lockForUpdate()->get();
$unavailable = $items->filter(fn ($item): bool => ! $this->availability->isAvailable(
$item->asset,
$item->starts_at,
$item->ends_at,
$locked->id,
));
if ($unavailable->isNotEmpty()) {
throw ValidationException::withMessages([
'decision' => 'A disponibilidade mudou: '.$unavailable->pluck('asset.name')->join(', ').'.',
]);
}
$this->transition($locked, $actor, ReservationStatus::Approved, 'approved', $comment, [
'approved_by' => $actor->id,
'approved_at' => now(),
'decision_reason' => $comment,
]);
$locked->items()->where('status', ReservationItemStatus::Held)->update([
'status' => ReservationItemStatus::Reserved,
]);
return $locked->load('requester', 'items.asset');
}, 3);
}
public function reject(Reservation $reservation, User $actor, string $reason): Reservation
{
$this->authorize($actor);
return DB::transaction(function () use ($reservation, $actor, $reason): Reservation {
$locked = $this->lockPending($reservation);
$this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [
'rejected_by' => $actor->id,
'rejected_at' => now(),
'decision_reason' => $reason,
]);
$locked->items()->whereIn('status', ReservationItemStatus::blockingValues())->update([
'status' => ReservationItemStatus::Cancelled,
]);
return $locked->load('requester', 'items.asset');
}, 3);
}
public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation
{
$this->authorize($actor);
return DB::transaction(function () use ($reservation, $actor, $comment): Reservation {
$locked = $this->lockPending($reservation);
$this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [
'decision_reason' => $comment,
'approval_deadline' => now()->addHours(48),
]);
return $locked->load('requester', 'items.asset');
}, 3);
}
private function authorize(User $actor): void
{
if (! $actor->active || (! $actor->hasRole(RoleName::Approver) && ! $actor->hasRole(RoleName::Administrator))) {
throw new AuthorizationException('Não tem permissão para decidir requisições.');
}
}
private function lockPending(Reservation $reservation): Reservation
{
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
if ($locked->status !== ReservationStatus::PendingApproval) {
throw ValidationException::withMessages(['decision' => 'A requisição já não aguarda uma decisão.']);
}
return $locked;
}
/** @param array<string, mixed> $attributes */
private function transition(Reservation $reservation, User $actor, ReservationStatus $next, string $action, ?string $comment, array $attributes): void
{
$previous = $reservation->status;
if (! $previous->canTransitionTo($next)) {
throw ValidationException::withMessages(['decision' => 'Transição de estado inválida.']);
}
$reservation->update(['status' => $next, ...$attributes]);
ApprovalAction::create([
'reservation_id' => $reservation->id,
'actor_id' => $actor->id,
'action' => $action,
'previous_status' => $previous->value,
'new_status' => $next->value,
'comment' => $comment,
]);
AuditLog::create([
'actor_id' => $actor->id,
'event' => "reservation.{$action}",
'auditable_type' => $reservation->getMorphClass(),
'auditable_id' => $reservation->id,
'before' => ['status' => $previous->value],
'after' => ['status' => $next->value, 'comment' => $comment],
]);
}
}

View File

@@ -4,9 +4,16 @@ namespace App\Domain\Reservations\Models;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property ReservationItemStatus $status
* @property CarbonImmutable $starts_at
* @property CarbonImmutable $ends_at
* @property SnipeAsset $asset
*/
class ReservationItem extends Model
{
protected $fillable = [

View File

@@ -25,14 +25,19 @@ class AvailabilityService
->where('ends_at', '>', $startsAt));
}
public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool
{
public function isAvailable(
SnipeAsset $asset,
DateTimeInterface $startsAt,
DateTimeInterface $endsAt,
?int $exceptReservationId = null,
): bool {
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {
return false;
}
$hasReservationConflict = $asset->reservationItems()
->whereIn('status', ReservationItemStatus::blockingValues())
->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId))
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->exists();

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Approvals\Services\ApprovalService;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ApprovalController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate(['search' => ['nullable', 'string', 'max:100']]);
return Inertia::render('approvals/index', [
'reservations' => Reservation::query()
->with(['requester:id,name,email,department', 'items.asset:id,name,asset_tag,category_name'])
->whereIn('status', [ReservationStatus::PendingApproval, ReservationStatus::AwaitingInformation])
->when($filters['search'] ?? null, fn ($query, string $search) => $query
->where(fn ($query) => $query
->where('reference', 'like', "%{$search}%")
->orWhereHas('requester', fn ($query) => $query->where('name', 'like', "%{$search}%"))))
->oldest('starts_at')
->paginate(20)
->withQueryString(),
'filters' => $filters,
]);
}
public function approve(Request $request, Reservation $reservation, ApprovalService $service): RedirectResponse
{
$data = $request->validate(['comment' => ['nullable', 'string', 'max:1000']]);
$service->approve($reservation, $request->user(), $data['comment'] ?? null);
return back()->with('success', "Requisição {$reservation->reference} aprovada.");
}
public function reject(Request $request, Reservation $reservation, ApprovalService $service): RedirectResponse
{
$data = $request->validate(['comment' => ['required', 'string', 'min:5', 'max:1000']]);
$service->reject($reservation, $request->user(), $data['comment']);
return back()->with('success', "Requisição {$reservation->reference} rejeitada.");
}
public function requestInformation(Request $request, Reservation $reservation, ApprovalService $service): RedirectResponse
{
$data = $request->validate(['comment' => ['required', 'string', 'min:5', 'max:1000']]);
$service->requestInformation($reservation, $request->user(), $data['comment']);
return back()->with('success', "Pedido de esclarecimentos enviado para {$reservation->reference}.");
}
}

View File

@@ -47,6 +47,7 @@ class HandleInertiaRequests extends Middleware
],
'can' => [
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];

View File

@@ -32,6 +32,11 @@ class AppServiceProvider extends ServiceProvider
'administer',
fn (User $user): bool => $user->active && $user->hasRole(RoleName::Administrator),
);
Gate::define(
'approve-reservations',
fn (User $user): bool => $user->active
&& ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)),
);
}
/**

View File

@@ -33,6 +33,7 @@ Nginx ──► Laravel + Inertia/React
- `app/Domain/Inventory`: cache local de ativos e consultas do catálogo.
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
- `app/Domain/Approvals`: decisões e concorrência de aprovação (a criar).
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação.
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
- `app/Domain/Audit`: logs imutáveis pela interface.
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.

View File

@@ -55,3 +55,10 @@ Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis at
**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)`.
## ADR-009 — Decisões com revalidação sob bloqueio
**Data:** 2026-08-04
**Estado:** aceite
A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou administrador ativo. Antes de aprovar, o serviço bloqueia a requisição e os ativos por ordem estável, ignora apenas os itens da própria pré-reserva e volta a validar estado, atribuição, bloqueios e conflitos externos. A decisão altera requisição e itens na mesma transação e escreve tanto `approval_actions` como `audit_logs`. Rejeição liberta os itens; um pedido de esclarecimentos mantém a pré-reserva e renova o prazo por 48 horas.

View File

@@ -1,7 +1,7 @@
# Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026
**Fase:** Fases 1, 2 e 3 concluídas
**Fase:** Fases 1, 2 e 3 concluídas; Fase 4 em curso
**Último agente:** Codex
## Resultado disponível
@@ -26,26 +26,29 @@
- 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.
- Área de aprovação protegida por perfil, com pesquisa por referência/requisitante.
- Aprovação, rejeição e pedido de esclarecimentos transacionais, registados em `approval_actions` e `audit_logs`.
- Aprovação revalida disponibilidade e estado operacional do ativo antes de promover itens `held` para `reserved`.
## 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 ainda não foram implementadas.
- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
- As notificações de decisão, a resposta do requisitante aos esclarecimentos e a substituição por equipamento equivalente 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.
- 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
Implementar a **Fase 4 — aprovações**:
Completar a **Fase 4 — aprovações**:
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;
1. abrir `app/Domain/Approvals/Services/ApprovalService.php` e criar notificações assíncronas para cada decisão;
2. permitir ao requisitante responder ao pedido de esclarecimentos e voltar a colocar o pedido em `pending_approval`;
3. acrescentar filtros da fila por data e categoria e uma vista de detalhe com histórico;
4. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos;
5. testar notificações, resposta, substituição e concorrência em PostgreSQL;
6. concluir a documentação da Fase 4;
7. atualizar este documento e o changelog.
## Testes da última sessão
@@ -53,13 +56,13 @@ Implementar a **Fase 4 — aprovações**:
Validação concluída em 4 de agosto de 2026:
- `docker compose exec -T app composer test`: passou;
- 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;
- Laravel Pint: 106 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 80 ficheiros, zero erros;
- Pest: 65 testes passaram, 289 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, 2306 módulos cliente e 95 módulos SSR transformados;
- Vite: builds cliente e SSR concluídos, 2307 módulos cliente e 96 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;

View File

@@ -97,6 +97,10 @@ 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.
## Aprovações
Utilizadores com perfil `approver` ou `administrator` acedem a `http://localhost:8000/aprovacoes`. Uma decisão válida deve criar uma linha em `approval_actions` e outra em `audit_logs`. Se a aprovação indicar que a disponibilidade mudou, confirmar primeiro a atribuição/estado sincronizado do ativo e conflitos entretanto criados; não alterar o estado diretamente na base de dados.
## Produção
Antes de disponibilizar:

View File

@@ -3,6 +3,7 @@ import {
CalendarDays,
CalendarRange,
LayoutGrid,
ListChecks,
PackageSearch,
PlusCircle,
ServerCog,
@@ -53,16 +54,26 @@ const footerNavItems: NavItem[] = [
export function AppSidebar() {
const { can } = usePage().props;
const visibleNavItems = can.administer
const roleNavItems = can.approveReservations
? [
...mainNavItems,
{
title: 'Aprovações',
href: '/aprovacoes',
icon: ListChecks,
},
]
: mainNavItems;
const visibleNavItems = can.administer
? [
...roleNavItems,
{
title: 'Integração Snipe-IT',
href: '/administracao/snipe-it',
icon: ServerCog,
},
]
: mainNavItems;
: roleNavItems;
return (
<Sidebar collapsible="icon" variant="inset">

View File

@@ -0,0 +1,204 @@
import { Head, router, usePage } from '@inertiajs/react';
import { Check, CircleHelp, Search, X } 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 { ReservationStatus } from '@/components/reservation-status';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import type { Paginated, Reservation } from '@/types';
type ApprovalReservation = Reservation & {
requester: {
name: string;
email: string;
department: string | null;
};
};
export default function ApprovalsIndex({
reservations,
filters,
}: {
reservations: Paginated<ApprovalReservation>;
filters: { search?: string };
}) {
const [search, setSearch] = useState(filters.search ?? '');
const [comments, setComments] = useState<Record<number, string>>({});
const { errors } = usePage().props;
function submitSearch(event: FormEvent) {
event.preventDefault();
router.get('/aprovacoes', { search: search || undefined });
}
function decide(reservation: ApprovalReservation, action: string) {
router.post(
`/aprovacoes/${reservation.uuid}/${action}`,
{ comment: comments[reservation.id] ?? '' },
{ preserveScroll: true },
);
}
return (
<>
<Head title="Aprovações" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Área do aprovador"
title="Requisições por decidir"
description="Reveja o período, a finalidade e os equipamentos antes de tomar uma decisão."
/>
<form onSubmit={submitSearch} className="flex gap-2">
<div className="relative max-w-md flex-1">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Referência ou requisitante…"
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Pesquisar
</Button>
</form>
<section className="space-y-4">
{reservations.data.map((reservation) => (
<Card key={reservation.id} className="shadow-none">
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle className="font-mono text-base">
{reservation.reference}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{reservation.requester.name} ·{' '}
{reservation.requester.department ??
reservation.requester.email}
</p>
</div>
<ReservationStatus
status={reservation.status}
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 text-sm sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground uppercase">
Equipamentos
</p>
<p className="mt-1 font-medium">
{reservation.items
.map((item) => item.asset.name)
.join(', ')}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase">
Período
</p>
<p className="mt-1 font-medium">
{formatPeriod(reservation)}
</p>
</div>
</div>
<p className="rounded-lg bg-slate-50 p-3 text-sm dark:bg-slate-900">
{reservation.purpose}
</p>
{reservation.status === 'pending_approval' ? (
<>
<textarea
value={
comments[reservation.id] ?? ''
}
onChange={(event) =>
setComments((current) => ({
...current,
[reservation.id]:
event.target.value,
}))
}
rows={2}
placeholder="Comentário opcional na aprovação; obrigatório para rejeitar ou pedir esclarecimentos…"
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<InputError
message={
errors.comment ??
errors.decision
}
/>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() =>
decide(
reservation,
'pedir-esclarecimentos',
)
}
>
<CircleHelp /> Pedir
esclarecimentos
</Button>
<Button
type="button"
variant="destructive"
onClick={() =>
decide(
reservation,
'rejeitar',
)
}
>
<X /> Rejeitar
</Button>
<Button
type="button"
onClick={() =>
decide(
reservation,
'aprovar',
)
}
>
<Check /> Aprovar
</Button>
</div>
</>
) : (
<p className="text-sm text-amber-700">
A aguardar resposta do requisitante.
</p>
)}
</CardContent>
</Card>
))}
{reservations.data.length === 0 && (
<div className="rounded-xl border border-dashed py-16 text-center text-sm text-muted-foreground">
Não existem requisições pendentes para os filtros
selecionados.
</div>
)}
</section>
</main>
</>
);
}
function formatPeriod(reservation: Reservation): string {
const format = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'short',
timeStyle: 'short',
});
return `${format.format(new Date(reservation.starts_at))} — ${format.format(new Date(reservation.ends_at))}`;
}
ApprovalsIndex.layout = {
breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }],
};

View File

@@ -18,6 +18,7 @@ declare module '@inertiajs/core' {
};
can: {
administer: boolean;
approveReservations: boolean;
};
sidebarOpen: boolean;
[key: string]: unknown;

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\SnipeItController;
use App\Http\Controllers\ApprovalController;
use App\Http\Controllers\AssetController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ReservationController;
@@ -17,6 +18,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
Route::middleware('can:approve-reservations')->prefix('aprovacoes')->name('approvals.')->group(function () {
Route::get('/', [ApprovalController::class, 'index'])->name('index');
Route::post('{reservation:uuid}/aprovar', [ApprovalController::class, 'approve'])->name('approve');
Route::post('{reservation:uuid}/rejeitar', [ApprovalController::class, 'reject'])->name('reject');
Route::post('{reservation:uuid}/pedir-esclarecimentos', [ApprovalController::class, 'requestInformation'])->name('request-information');
});
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');

View File

@@ -0,0 +1,121 @@
<?php
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function pendingApprovalReservation(User $requester, SnipeAsset $asset, string $reference): Reservation
{
$reservation = Reservation::create([
'reference' => $reference,
'requester_id' => $requester->id,
'purpose' => 'Produção audiovisual para uma atividade pedagógica.',
'usage_location' => 'Auditório',
'starts_at' => now()->addDays(3),
'ends_at' => now()->addDays(3)->addHours(2),
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->addDay(),
]);
$reservation->items()->create([
'snipe_asset_id' => $asset->id,
'starts_at' => $reservation->starts_at,
'ends_at' => $reservation->ends_at,
'status' => ReservationItemStatus::Held,
]);
return $reservation;
}
function approverUser(): User
{
$user = User::factory()->create();
$role = Role::create(['name' => RoleName::Approver->value]);
$user->roles()->attach($role);
return $user;
}
it('aprova uma requisição após revalidar disponibilidade', function () {
$approver = approverUser();
$reservation = pendingApprovalReservation(
User::factory()->create(),
SnipeAsset::factory()->create(),
'REQ-APPROVE-001',
);
$this->actingAs($approver)
->post("/aprovacoes/{$reservation->uuid}/aprovar", ['comment' => 'Pedido adequado.'])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Approved)
->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Reserved);
$this->assertDatabaseHas('approval_actions', [
'reservation_id' => $reservation->id,
'actor_id' => $approver->id,
'action' => 'approved',
]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.approved']);
});
it('rejeita uma requisição e liberta a pré-reserva', function () {
$approver = approverUser();
$reservation = pendingApprovalReservation(
User::factory()->create(),
SnipeAsset::factory()->create(),
'REQ-APPROVE-002',
);
$this->actingAs($approver)
->post("/aprovacoes/{$reservation->uuid}/rejeitar", ['comment' => 'O equipamento não pode sair da escola.'])
->assertRedirect();
expect($reservation->fresh()->status)->toBe(ReservationStatus::Rejected)
->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Cancelled);
});
it('regista um pedido de esclarecimentos e renova o prazo', function () {
$approver = approverUser();
$reservation = pendingApprovalReservation(
User::factory()->create(),
SnipeAsset::factory()->create(),
'REQ-APPROVE-003',
);
$oldDeadline = $reservation->approval_deadline;
$this->actingAs($approver)
->post("/aprovacoes/{$reservation->uuid}/pedir-esclarecimentos", [
'comment' => 'Indique quem ficará responsável pelo transporte.',
])
->assertRedirect();
expect($reservation->fresh()->status)->toBe(ReservationStatus::AwaitingInformation)
->and($reservation->fresh()->approval_deadline->isAfter($oldDeadline))->toBeTrue();
$this->assertDatabaseHas('approval_actions', ['action' => 'information_requested']);
});
it('impede requisitantes de aceder à área de aprovação', function () {
$this->actingAs(User::factory()->create())->get('/aprovacoes')->assertForbidden();
});
it('recusa aprovação se o estado operacional do ativo mudou', function () {
$approver = approverUser();
$asset = SnipeAsset::factory()->create();
$reservation = pendingApprovalReservation(User::factory()->create(), $asset, 'REQ-APPROVE-004');
$asset->update(['assigned_id' => 99]);
$this->actingAs($approver)
->from('/aprovacoes')
->post("/aprovacoes/{$reservation->uuid}/aprovar")
->assertRedirect('/aprovacoes')
->assertSessionHasErrors('decision');
expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval);
});