feat: add transactional approval workflow
This commit is contained in:
@@ -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”.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
30
app/Domain/Approvals/Models/ApprovalAction.php
Normal file
30
app/Domain/Approvals/Models/ApprovalAction.php
Normal 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');
|
||||
}
|
||||
}
|
||||
133
app/Domain/Approvals/Services/ApprovalService.php
Normal file
133
app/Domain/Approvals/Services/ApprovalService.php
Normal 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
|
||||
57
app/Http/Controllers/ApprovalController.php
Normal file
57
app/Http/Controllers/ApprovalController.php
Normal 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}.");
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
204
resources/js/pages/approvals/index.tsx
Normal file
204
resources/js/pages/approvals/index.tsx
Normal 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' }],
|
||||
};
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -18,6 +18,7 @@ declare module '@inertiajs/core' {
|
||||
};
|
||||
can: {
|
||||
administer: boolean;
|
||||
approveReservations: boolean;
|
||||
};
|
||||
sidebarOpen: boolean;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -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');
|
||||
|
||||
121
tests/Feature/ApprovalWorkflowTest.php
Normal file
121
tests/Feature/ApprovalWorkflowTest.php
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user