feat: complete approval workflow

This commit is contained in:
2026-08-04 16:51:51 +01:00
parent c2136bc250
commit 809eab9d4a
26 changed files with 1156 additions and 41 deletions

View File

@@ -24,6 +24,11 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Calendário mensal pessoal com isolamento por utilizador e navegação entre meses. - 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. - 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. - Revalidação transacional da disponibilidade antes da aprovação, promoção de itens para `reserved` e histórico em `approval_actions`/auditoria.
- Notificações assíncronas por email e caixa interna para submissões, decisões, esclarecimentos e substituições.
- Centro de notificações isolado por utilizador, com contador e marcação de leitura.
- Resposta auditada a pedidos de esclarecimento e renovação do prazo de decisão.
- Substituição controlada por equipamento disponível da mesma categoria, preservando o item original no histórico.
- Filtros da fila de aprovação por categoria/período e histórico de ações apresentado na interface.
### Alterado ### Alterado
@@ -42,7 +47,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
### Verificado ### Verificado
- Migrations e seeders executados em PostgreSQL 17 real. - Migrations e seeders executados em PostgreSQL 17 real.
- Suite backend: Pint em 106 ficheiros, PHPStan nível 7 em 80 ficheiros e 65 testes Pest (289 asserções). - Suite backend: Pint em 111 ficheiros, PHPStan nível 7 em 84 ficheiros e 70 testes Pest (340 asserções).
- Frontend: TypeScript, ESLint, Prettier e build Vite de produção. - Frontend: TypeScript, ESLint, Prettier e build Vite de produção.
- Stack Docker operacional e endpoint de login acessível através do Nginx. - 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”. - Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”.
@@ -50,3 +55,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17. - Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17.
- Comando de expiração executado em PostgreSQL e agendamento a cada cinco minutos confirmado. - 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. - Builds Vite cliente e SSR com as páginas de disponibilidade, cancelamento e calendário concluídos.
- Migration da caixa interna de notificações aplicada em PostgreSQL 17.

View File

@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
## Estado atual ## 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**. 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. O projeto concluiu as **Fases 1 a 4**: fundação técnica, integração de leitura Snipe-IT, motor de reservas e aprovações. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, cancelamento e expiração, calendário pessoal, decisões transacionais, esclarecimentos, substituição equivalente, notificações por email/caixa interna, dashboard e diagnóstico administrativo. A próxima entrega implementa o fluxo físico de entrega e devoluçã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. 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

@@ -9,8 +9,10 @@ use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation; use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Models\ReservationItem;
use App\Domain\Reservations\Services\AvailabilityService; use App\Domain\Reservations\Services\AvailabilityService;
use App\Models\User; use App\Models\User;
use App\Notifications\ReservationDecisionNotification;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -23,7 +25,7 @@ class ApprovalService
{ {
$this->authorize($actor); $this->authorize($actor);
return DB::transaction(function () use ($reservation, $actor, $comment): Reservation { $approved = DB::transaction(function () use ($reservation, $actor, $comment): Reservation {
$locked = $this->lockPending($reservation); $locked = $this->lockPending($reservation);
$items = $locked->items()->with('asset')->get(); $items = $locked->items()->with('asset')->get();
SnipeAsset::query()->whereKey($items->pluck('snipe_asset_id'))->orderBy('id')->lockForUpdate()->get(); SnipeAsset::query()->whereKey($items->pluck('snipe_asset_id'))->orderBy('id')->lockForUpdate()->get();
@@ -51,13 +53,17 @@ class ApprovalService
return $locked->load('requester', 'items.asset'); return $locked->load('requester', 'items.asset');
}, 3); }, 3);
$approved->requester->notify(new ReservationDecisionNotification($approved, 'approved', $comment));
return $approved;
} }
public function reject(Reservation $reservation, User $actor, string $reason): Reservation public function reject(Reservation $reservation, User $actor, string $reason): Reservation
{ {
$this->authorize($actor); $this->authorize($actor);
return DB::transaction(function () use ($reservation, $actor, $reason): Reservation { $rejected = DB::transaction(function () use ($reservation, $actor, $reason): Reservation {
$locked = $this->lockPending($reservation); $locked = $this->lockPending($reservation);
$this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [ $this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [
'rejected_by' => $actor->id, 'rejected_by' => $actor->id,
@@ -70,13 +76,17 @@ class ApprovalService
return $locked->load('requester', 'items.asset'); return $locked->load('requester', 'items.asset');
}, 3); }, 3);
$rejected->requester->notify(new ReservationDecisionNotification($rejected, 'rejected', $reason));
return $rejected;
} }
public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation
{ {
$this->authorize($actor); $this->authorize($actor);
return DB::transaction(function () use ($reservation, $actor, $comment): Reservation { $awaitingInformation = DB::transaction(function () use ($reservation, $actor, $comment): Reservation {
$locked = $this->lockPending($reservation); $locked = $this->lockPending($reservation);
$this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [ $this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [
'decision_reason' => $comment, 'decision_reason' => $comment,
@@ -85,6 +95,81 @@ class ApprovalService
return $locked->load('requester', 'items.asset'); return $locked->load('requester', 'items.asset');
}, 3); }, 3);
$awaitingInformation->requester->notify(
new ReservationDecisionNotification($awaitingInformation, 'information_requested', $comment)
);
return $awaitingInformation;
}
public function substituteAsset(
Reservation $reservation,
ReservationItem $item,
SnipeAsset $replacement,
User $actor,
string $reason,
): Reservation {
$this->authorize($actor);
$updated = DB::transaction(function () use ($reservation, $item, $replacement, $actor, $reason): Reservation {
$locked = $this->lockPending($reservation);
$lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
if ($lockedItem->reservation_id !== $locked->id || $lockedItem->status !== ReservationItemStatus::Held) {
throw ValidationException::withMessages(['replacement_asset_id' => 'O item já não pode ser substituído.']);
}
$assets = SnipeAsset::query()
->whereKey([$lockedItem->snipe_asset_id, $replacement->id])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$original = $assets->get($lockedItem->snipe_asset_id);
$newAsset = $assets->get($replacement->id);
if ($original === null || $newAsset === null || ! $this->sameCategory($original, $newAsset)) {
throw ValidationException::withMessages(['replacement_asset_id' => 'Selecione um equipamento da mesma categoria.']);
}
if ($locked->items()->where('snipe_asset_id', $newAsset->id)->exists()
|| ! $this->availability->isAvailable($newAsset, $lockedItem->starts_at, $lockedItem->ends_at)) {
throw ValidationException::withMessages(['replacement_asset_id' => 'O equipamento substituto não está disponível.']);
}
$lockedItem->update(['status' => ReservationItemStatus::Cancelled]);
$locked->items()->create([
'snipe_asset_id' => $newAsset->id,
'starts_at' => $lockedItem->starts_at,
'ends_at' => $lockedItem->ends_at,
'status' => ReservationItemStatus::Held,
'notes' => "Substitui {$original->asset_tag}: {$reason}",
]);
ApprovalAction::create([
'reservation_id' => $locked->id,
'actor_id' => $actor->id,
'action' => 'asset_substituted',
'previous_status' => $locked->status->value,
'new_status' => $locked->status->value,
'comment' => $reason,
]);
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'reservation.asset_substituted',
'auditable_type' => $locked->getMorphClass(),
'auditable_id' => $locked->id,
'metadata' => [
'original_asset_id' => $original->id,
'replacement_asset_id' => $newAsset->id,
'reason' => $reason,
],
]);
return $locked->load('requester', 'items.asset');
}, 3);
$updated->requester->notify(new ReservationDecisionNotification($updated, 'asset_substituted', $reason));
return $updated;
} }
private function authorize(User $actor): void private function authorize(User $actor): void
@@ -94,6 +179,15 @@ class ApprovalService
} }
} }
private function sameCategory(SnipeAsset $original, SnipeAsset $replacement): bool
{
if ($original->category_id !== null && $replacement->category_id !== null) {
return $original->category_id === $replacement->category_id;
}
return filled($original->category_name) && $original->category_name === $replacement->category_name;
}
private function lockPending(Reservation $reservation): Reservation private function lockPending(Reservation $reservation): Reservation
{ {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();

View File

@@ -2,6 +2,7 @@
namespace App\Domain\Reservations\Models; namespace App\Domain\Reservations\Models;
use App\Domain\Approvals\Models\ApprovalAction;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Models\User; use App\Models\User;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@@ -57,4 +58,10 @@ class Reservation extends Model
{ {
return $this->hasMany(ReservationItem::class); return $this->hasMany(ReservationItem::class);
} }
/** @return HasMany<ApprovalAction, $this> */
public function approvalActions(): HasMany
{
return $this->hasMany(ApprovalAction::class)->latest('created_at');
}
} }

View File

@@ -2,15 +2,19 @@
namespace App\Domain\Reservations\Services; namespace App\Domain\Reservations\Services;
use App\Domain\Approvals\Models\ApprovalAction;
use App\Domain\Audit\Models\AuditLog; use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation; use App\Domain\Reservations\Models\Reservation;
use App\Models\User; use App\Models\User;
use App\Notifications\ReservationSubmittedNotification;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -26,7 +30,7 @@ class ReservationService
$startsAt = CarbonImmutable::parse($data['starts_at']); $startsAt = CarbonImmutable::parse($data['starts_at']);
$endsAt = CarbonImmutable::parse($data['ends_at']); $endsAt = CarbonImmutable::parse($data['ends_at']);
return DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation { $reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation {
$assets = SnipeAsset::query() $assets = SnipeAsset::query()
->whereKey($data['asset_ids']) ->whereKey($data['asset_ids'])
->lockForUpdate() ->lockForUpdate()
@@ -75,8 +79,19 @@ class ReservationService
'metadata' => ['asset_ids' => $assets->modelKeys()], 'metadata' => ['asset_ids' => $assets->modelKeys()],
]); ]);
return $reservation->load('items.asset'); return $reservation->load('requester', 'items.asset');
}, 3); }, 3);
$approvers = User::query()
->where('active', true)
->whereHas('roles', fn ($query) => $query->whereIn('name', [
RoleName::Approver->value,
RoleName::Administrator->value,
]))
->get();
Notification::send($approvers, new ReservationSubmittedNotification($reservation));
return $reservation;
} }
public function cancel(Reservation $reservation, User $actor, string $reason): Reservation public function cancel(Reservation $reservation, User $actor, string $reason): Reservation
@@ -124,6 +139,61 @@ class ReservationService
}, 3); }, 3);
} }
public function respondToInformation(Reservation $reservation, User $actor, string $response): Reservation
{
if ($reservation->requester_id !== $actor->id) {
throw new AuthorizationException('Apenas o requisitante pode responder a este pedido.');
}
$updated = DB::transaction(function () use ($reservation, $actor, $response): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
if (! $locked->status->canTransitionTo(ReservationStatus::PendingApproval)
|| $locked->status !== ReservationStatus::AwaitingInformation) {
throw ValidationException::withMessages([
'response' => 'Esta requisição não aguarda esclarecimentos.',
]);
}
$locked->update([
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->addHours(48),
]);
ApprovalAction::create([
'reservation_id' => $locked->id,
'actor_id' => $actor->id,
'action' => 'information_provided',
'previous_status' => ReservationStatus::AwaitingInformation->value,
'new_status' => ReservationStatus::PendingApproval->value,
'comment' => $response,
]);
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'reservation.information_provided',
'auditable_type' => $locked->getMorphClass(),
'auditable_id' => $locked->id,
'before' => ['status' => ReservationStatus::AwaitingInformation->value],
'after' => ['status' => ReservationStatus::PendingApproval->value],
'metadata' => ['response' => $response],
]);
return $locked->load('requester', 'items.asset');
}, 3);
$approvers = User::query()
->where('active', true)
->whereHas('roles', fn ($query) => $query->whereIn('name', [
RoleName::Approver->value,
RoleName::Administrator->value,
]))
->get();
Notification::send(
$approvers,
new ReservationSubmittedNotification($updated, 'information_provided'),
);
return $updated;
}
public function expirePending(int $limit = 100): int public function expirePending(int $limit = 100): int
{ {
$reservationIds = Reservation::query() $reservationIds = Reservation::query()

View File

@@ -3,8 +3,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Domain\Approvals\Services\ApprovalService; use App\Domain\Approvals\Services\ApprovalService;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation; use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Models\ReservationItem;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -14,20 +17,46 @@ class ApprovalController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
$filters = $request->validate(['search' => ['nullable', 'string', 'max:100']]); $filters = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'category' => ['nullable', 'string', 'max:100'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
]);
return Inertia::render('approvals/index', [ return Inertia::render('approvals/index', [
'reservations' => Reservation::query() 'reservations' => Reservation::query()
->with(['requester:id,name,email,department', 'items.asset:id,name,asset_tag,category_name']) ->with([
'requester:id,name,email,department',
'items.asset:id,name,asset_tag,category_id,category_name',
'approvalActions.actor:id,name',
])
->whereIn('status', [ReservationStatus::PendingApproval, ReservationStatus::AwaitingInformation]) ->whereIn('status', [ReservationStatus::PendingApproval, ReservationStatus::AwaitingInformation])
->when($filters['search'] ?? null, fn ($query, string $search) => $query ->when($filters['search'] ?? null, fn ($query, string $search) => $query
->where(fn ($query) => $query ->where(fn ($query) => $query
->where('reference', 'like', "%{$search}%") ->where('reference', 'like', "%{$search}%")
->orWhereHas('requester', fn ($query) => $query->where('name', 'like', "%{$search}%")))) ->orWhereHas('requester', fn ($query) => $query->where('name', 'like', "%{$search}%"))))
->when($filters['category'] ?? null, fn ($query, string $category) => $query
->whereHas('items.asset', fn ($query) => $query->where('category_name', $category)))
->when($filters['date_from'] ?? null, fn ($query, string $date) => $query
->where('ends_at', '>', CarbonImmutable::parse($date)->startOfDay()))
->when($filters['date_to'] ?? null, fn ($query, string $date) => $query
->where('starts_at', '<', CarbonImmutable::parse($date)->addDay()->startOfDay()))
->oldest('starts_at') ->oldest('starts_at')
->paginate(20) ->paginate(20)
->withQueryString(), ->withQueryString(),
'filters' => $filters, 'filters' => $filters,
'categories' => SnipeAsset::query()
->whereNotNull('category_name')
->distinct()
->orderBy('category_name')
->pluck('category_name'),
'replacementAssets' => SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->orderBy('name')
->get(['id', 'name', 'asset_tag', 'category_id', 'category_name']),
]); ]);
} }
@@ -54,4 +83,17 @@ class ApprovalController extends Controller
return back()->with('success', "Pedido de esclarecimentos enviado para {$reservation->reference}."); return back()->with('success', "Pedido de esclarecimentos enviado para {$reservation->reference}.");
} }
public function substitute(Request $request, Reservation $reservation, ReservationItem $item, ApprovalService $service): RedirectResponse
{
$data = $request->validate([
'replacement_asset_id' => ['required', 'integer', 'exists:snipe_assets,id'],
'comment' => ['required', 'string', 'min:5', 'max:1000'],
]);
/** @var SnipeAsset $replacement */
$replacement = SnipeAsset::query()->findOrFail($data['replacement_asset_id']);
$service->substituteAsset($reservation, $item, $replacement, $request->user(), $data['comment']);
return back()->with('success', "Equipamento substituído na requisição {$reservation->reference}.");
}
} }

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class NotificationController extends Controller
{
public function index(Request $request): Response
{
return Inertia::render('notifications/index', [
'notifications' => $request->user()->notifications()->latest()->paginate(20),
]);
}
public function read(Request $request, string $notification): RedirectResponse
{
$item = $request->user()->notifications()->whereKey($notification)->firstOrFail();
$item->markAsRead();
$url = is_string($item->data['url'] ?? null) ? $item->data['url'] : '/notificacoes';
return redirect()->to($url);
}
public function readAll(Request $request): RedirectResponse
{
$request->user()->unreadNotifications->markAsRead();
return back()->with('success', 'Notificações marcadas como lidas.');
}
}

View File

@@ -95,4 +95,14 @@ class ReservationController extends Controller
return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis."); return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis.");
} }
public function respondToInformation(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse
{
$data = $request->validate([
'response' => ['required', 'string', 'min:5', 'max:2000'],
]);
$service->respondToInformation($reservation, $request->user(), $data['response']);
return back()->with('success', "Esclarecimentos enviados para a requisição {$reservation->reference}.");
}
} }

View File

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

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Notifications;
use App\Domain\Reservations\Models\Reservation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ReservationDecisionNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Reservation $reservation,
public readonly string $event,
public readonly ?string $comment = null,
) {
$this->afterCommit();
}
/** @return list<string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject($this->subject())
->greeting("Atualização da requisição {$this->reservation->reference}")
->line($this->message());
if (filled($this->comment)) {
$message->line("Comentário: {$this->comment}");
}
return $message->action('Consultar requisições', url('/requisicoes'));
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'reservation_uuid' => $this->reservation->uuid,
'reference' => $this->reservation->reference,
'event' => $this->event,
'message' => $this->message(),
'comment' => $this->comment,
'url' => '/requisicoes',
];
}
private function subject(): string
{
return match ($this->event) {
'approved' => "Requisição {$this->reservation->reference} aprovada",
'rejected' => "Requisição {$this->reservation->reference} rejeitada",
'information_requested' => "Esclarecimentos necessários para {$this->reservation->reference}",
'asset_substituted' => "Equipamento alterado em {$this->reservation->reference}",
default => "Atualização da requisição {$this->reservation->reference}",
};
}
private function message(): string
{
return match ($this->event) {
'approved' => 'A sua requisição foi aprovada.',
'rejected' => 'A sua requisição foi rejeitada.',
'information_requested' => 'O aprovador pediu informações adicionais.',
'asset_substituted' => 'Um equipamento da sua requisição foi substituído por outro equivalente.',
default => 'O estado da sua requisição foi atualizado.',
};
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Notifications;
use App\Domain\Reservations\Models\Reservation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ReservationSubmittedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly Reservation $reservation,
public readonly string $event = 'submitted',
) {
$this->afterCommit();
}
/** @return list<string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->event === 'information_provided'
? "Resposta recebida para {$this->reservation->reference}"
: "Nova requisição {$this->reservation->reference}")
->greeting($this->event === 'information_provided'
? 'O requisitante respondeu ao pedido de esclarecimentos'
: 'Nova requisição para aprovação')
->line("{$this->reservation->requester->name} submeteu a requisição {$this->reservation->reference} para decisão.")
->line("Período: {$this->reservation->starts_at->format('d/m/Y H:i')} — {$this->reservation->ends_at->format('d/m/Y H:i')}.")
->action('Analisar requisição', url('/aprovacoes'));
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'reservation_uuid' => $this->reservation->uuid,
'reference' => $this->reservation->reference,
'event' => $this->event,
'message' => $this->event === 'information_provided'
? "Foram prestados esclarecimentos para {$this->reservation->reference}."
: "Nova requisição {$this->reservation->reference} aguarda decisão.",
'url' => '/aprovacoes',
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestampTz('read_at')->nullable();
$table->timestampsTz();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -32,7 +32,6 @@ Nginx ──► Laravel + Inertia/React
- `app/Domain/Identity`: perfis, permissões e identidades externas. - `app/Domain/Identity`: perfis, permissões e identidades externas.
- `app/Domain/Inventory`: cache local de ativos e consultas do catálogo. - `app/Domain/Inventory`: cache local de ativos e consultas do catálogo.
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições. - `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/Approvals`: decisões, histórico e concorrência de aprovação.
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar). - `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
- `app/Domain/Audit`: logs imutáveis pela interface. - `app/Domain/Audit`: logs imutáveis pela interface.
@@ -53,6 +52,10 @@ Uma criação ou aprovação deve bloquear as linhas dos ativos, voltar a valida
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. 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.
`ApprovalService` bloqueia a requisição e os ativos antes de aprovar ou substituir, revalida a disponibilidade e regista cada passo em `approval_actions` e `audit_logs`. Uma substituição cancela o item original e cria um novo `held`, apenas na mesma categoria. Pedidos e respostas de esclarecimento conservam o histórico e renovam explicitamente o prazo.
Notificações implementam `ShouldQueue`, usam `afterCommit` e seguem dois canais: email e tabela `notifications`. A caixa interna consulta exclusivamente a relação polimórfica do utilizador autenticado, evitando acesso horizontal a notificações de terceiros.
## Fluxo de dados Snipe-IT ## Fluxo de dados Snipe-IT
1. Um job consulta a API paginada com token de leitura. 1. Um job consulta a API paginada com token de leitura.

View File

@@ -62,3 +62,10 @@ Uma requisição submetida cria itens em `held` e bloqueia o período durante 48
**Estado:** aceite **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. 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.
## ADR-010 — Notificações após commit e substituição equivalente
**Data:** 2026-08-04
**Estado:** aceite
Notificações funcionais são colocadas na fila apenas depois da confirmação da transação e enviadas por email e caixa interna. A submissão e uma resposta a esclarecimentos notificam aprovadores ativos; decisões e substituições notificam o requisitante. Uma substituição não altera automaticamente a decisão: exige justificação, equipamento livre da mesma categoria, bloqueio de ambas as linhas, novo item `held` e preservação do item original como `cancelled` para auditoria.

View File

@@ -1,7 +1,7 @@
# Estado do projeto e handoff # Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026 **Atualizado em:** 4 de agosto de 2026
**Fase:** Fases 1, 2 e 3 concluídas; Fase 4 em curso **Fase:** Fases 1, 2, 3 e 4 concluídas
**Último agente:** Codex **Último agente:** Codex
## Resultado disponível ## Resultado disponível
@@ -29,26 +29,31 @@
- Área de aprovação protegida por perfil, com pesquisa por referência/requisitante. - Á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, 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`. - Aprovação revalida disponibilidade e estado operacional do ativo antes de promover itens `held` para `reserved`.
- Notificações assíncronas por email e base de dados para submissões, decisões, esclarecimentos e substituições.
- Caixa interna de notificações, contador de não lidas, marcação individual/coletiva e isolamento por utilizador.
- Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas.
- Substituição de equipamento apenas por ativo livre da mesma categoria, mantendo a pré-reserva e todo o histórico.
- Fila de aprovação com filtros por referência, requisitante, categoria e período, incluindo histórico de ações.
## Limites conhecidos ## 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. - 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. - Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas.
- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface. - 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 notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto.
- 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. - 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. - 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 ## Próxima tarefa recomendada
Completar a **Fase 4 — aprovações**: Implementar a **Fase 5 — entrega e devolução**:
1. abrir `app/Domain/Approvals/Services/ApprovalService.php` e criar notificações assíncronas para cada decisão; 1. abrir `app/Integrations/SnipeIt/SnipeItClient.php` e acrescentar checkout/check-in com o token operacional separado;
2. permitir ao requisitante responder ao pedido de esclarecimentos e voltar a colocar o pedido em `pending_approval`; 2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais;
3. acrescentar filtros da fila por data e categoria e uma vista de detalhe com histórico; 3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico;
4. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos; 4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída;
5. testar notificações, resposta, substituição e concorrência em PostgreSQL; 5. implementar incidentes, atrasos e bloqueios associados a dano;
6. concluir a documentação da Fase 4; 6. testar API simulada, autorização, idempotência e auditoria;
7. atualizar este documento e o changelog. 7. atualizar este documento e o changelog.
## Testes da última sessão ## Testes da última sessão
@@ -56,20 +61,21 @@ Completar a **Fase 4 — aprovações**:
Validação concluída em 4 de agosto de 2026: Validação concluída em 4 de agosto de 2026:
- `docker compose exec -T app composer test`: passou; - `docker compose exec -T app composer test`: passou;
- Laravel Pint: 106 ficheiros sem desvios; - Laravel Pint: 111 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 80 ficheiros, zero erros; - PHPStan/Larastan nível 7: 84 ficheiros, zero erros;
- Pest: 65 testes passaram, 289 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- TypeScript (`tsc --noEmit`): passou; - TypeScript (`tsc --noEmit`): passou;
- ESLint: passou; - ESLint: passou;
- Prettier: passou; - Prettier: passou;
- Vite: builds cliente e SSR concluídos, 2307 módulos cliente e 96 módulos SSR transformados; - Vite: builds cliente e SSR concluídos, 2308 módulos cliente e 97 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: `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: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados; - PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
- PostgreSQL 17: migration `notifications` aplicada com sucesso;
- smoke test HTTP: `/login` respondeu com estado 200 através do Nginx; - 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`; - 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; - Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia;
- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; catálogo, cancelamento e calendário foram validados por testes 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; aprovações, esclarecimentos e notificações foram validados por testes Inertia, TypeScript e builds cliente/SSR.
## Decisões pendentes do contexto institucional ## Decisões pendentes do contexto institucional

View File

@@ -101,6 +101,8 @@ Uma expiração ou cancelamento muda itens `held`, `reserved`, `ready` e `delive
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. 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.
As decisões geram jobs de notificação. Em desenvolvimento, confirmar o email em `http://localhost:8025` e a notificação interna em `http://localhost:8000/notificacoes`. Se uma mensagem não chegar, consultar primeiro `docker compose logs --tail=100 queue` e a tabela `failed_jobs`; não repetir a decisão já confirmada.
## Produção ## Produção
Antes de disponibilizar: Antes de disponibilizar:

View File

@@ -1,5 +1,6 @@
import { Link, usePage } from '@inertiajs/react'; import { Link, usePage } from '@inertiajs/react';
import { import {
Bell,
CalendarDays, CalendarDays,
CalendarRange, CalendarRange,
LayoutGrid, LayoutGrid,
@@ -42,6 +43,7 @@ const mainNavItems: NavItem[] = [
href: '/requisicoes/calendario', href: '/requisicoes/calendario',
icon: CalendarRange, icon: CalendarRange,
}, },
{ title: 'Notificações', href: '/notificacoes', icon: Bell },
]; ];
const footerNavItems: NavItem[] = [ const footerNavItems: NavItem[] = [
@@ -53,17 +55,22 @@ const footerNavItems: NavItem[] = [
]; ];
export function AppSidebar() { export function AppSidebar() {
const { can } = usePage().props; const { can, unreadNotifications } = usePage().props;
const userNavItems = mainNavItems.map((item) =>
item.href === '/notificacoes' && unreadNotifications > 0
? { ...item, title: `Notificações (${unreadNotifications})` }
: item,
);
const roleNavItems = can.approveReservations const roleNavItems = can.approveReservations
? [ ? [
...mainNavItems, ...userNavItems,
{ {
title: 'Aprovações', title: 'Aprovações',
href: '/aprovacoes', href: '/aprovacoes',
icon: ListChecks, icon: ListChecks,
}, },
] ]
: mainNavItems; : userNavItems;
const visibleNavItems = can.administer const visibleNavItems = can.administer
? [ ? [
...roleNavItems, ...roleNavItems,

View File

@@ -1,5 +1,5 @@
import { Head, router, usePage } from '@inertiajs/react'; import { Head, Link, router, usePage } from '@inertiajs/react';
import { Check, CircleHelp, Search, X } from 'lucide-react'; import { Check, CircleHelp, RefreshCw, Search, X } from 'lucide-react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
@@ -8,7 +8,7 @@ import { ReservationStatus } from '@/components/reservation-status';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import type { Paginated, Reservation } from '@/types'; import type { Asset, Paginated, Reservation } from '@/types';
type ApprovalReservation = Reservation & { type ApprovalReservation = Reservation & {
requester: { requester: {
@@ -16,22 +16,49 @@ type ApprovalReservation = Reservation & {
email: string; email: string;
department: string | null; department: string | null;
}; };
approval_actions: {
id: number;
action: string;
comment: string | null;
created_at: string;
actor: { name: string };
}[];
}; };
export default function ApprovalsIndex({ export default function ApprovalsIndex({
reservations, reservations,
filters, filters,
replacementAssets,
categories,
}: { }: {
reservations: Paginated<ApprovalReservation>; reservations: Paginated<ApprovalReservation>;
filters: { search?: string }; filters: {
search?: string;
category?: string;
date_from?: string;
date_to?: string;
};
replacementAssets: Asset[];
categories: string[];
}) { }) {
const [search, setSearch] = useState(filters.search ?? ''); const [search, setSearch] = useState(filters.search ?? '');
const [category, setCategory] = useState(filters.category ?? '');
const [dateFrom, setDateFrom] = useState(filters.date_from ?? '');
const [dateTo, setDateTo] = useState(filters.date_to ?? '');
const [comments, setComments] = useState<Record<number, string>>({}); const [comments, setComments] = useState<Record<number, string>>({});
const [replacements, setReplacements] = useState<Record<number, string>>(
{},
);
const { errors } = usePage().props; const { errors } = usePage().props;
function submitSearch(event: FormEvent) { function submitSearch(event: FormEvent) {
event.preventDefault(); event.preventDefault();
router.get('/aprovacoes', { search: search || undefined }); router.get('/aprovacoes', {
search: search || undefined,
category: category || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
});
} }
function decide(reservation: ApprovalReservation, action: string) { function decide(reservation: ApprovalReservation, action: string) {
@@ -42,6 +69,17 @@ export default function ApprovalsIndex({
); );
} }
function substitute(reservation: ApprovalReservation, itemId: number) {
router.post(
`/aprovacoes/${reservation.uuid}/itens/${itemId}/substituir`,
{
replacement_asset_id: Number(replacements[itemId]),
comment: comments[reservation.id] ?? '',
},
{ preserveScroll: true },
);
}
return ( return (
<> <>
<Head title="Aprovações" /> <Head title="Aprovações" />
@@ -51,8 +89,11 @@ export default function ApprovalsIndex({
title="Requisições por decidir" title="Requisições por decidir"
description="Reveja o período, a finalidade e os equipamentos antes de tomar uma decisão." description="Reveja o período, a finalidade e os equipamentos antes de tomar uma decisão."
/> />
<form onSubmit={submitSearch} className="flex gap-2"> <form
<div className="relative max-w-md flex-1"> onSubmit={submitSearch}
className="grid gap-2 rounded-xl border bg-card p-4 md:grid-cols-[1fr_200px_170px_170px_auto]"
>
<div className="relative">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" /> <Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input <Input
value={search} value={search}
@@ -61,6 +102,30 @@ export default function ApprovalsIndex({
className="pl-9" className="pl-9"
/> />
</div> </div>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
className="rounded-md border bg-transparent px-3 text-sm"
>
<option value="">Todas as categorias</option>
{categories.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<Input
type="date"
aria-label="Data inicial"
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
<Input
type="date"
aria-label="Data final"
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
/>
<Button type="submit" variant="secondary"> <Button type="submit" variant="secondary">
Pesquisar Pesquisar
</Button> </Button>
@@ -90,11 +155,131 @@ export default function ApprovalsIndex({
<p className="text-xs text-muted-foreground uppercase"> <p className="text-xs text-muted-foreground uppercase">
Equipamentos Equipamentos
</p> </p>
<p className="mt-1 font-medium"> <div className="mt-1 space-y-2">
{reservation.items {reservation.items
.map((item) => item.asset.name) .filter(
.join(', ')} (item) =>
</p> item.status !==
'cancelled',
)
.map((item) => {
const candidates =
replacementAssets.filter(
(asset) =>
asset.id !==
item.asset
.id &&
(item.asset
.category_id !=
null
? asset.category_id ===
item.asset
.category_id
: asset.category_name ===
item.asset
.category_name),
);
return (
<div
key={item.id}
className="space-y-1"
>
<p className="font-medium">
{
item.asset
.name
}{' '}
<span className="font-mono text-xs text-muted-foreground">
{
item
.asset
.asset_tag
}
</span>
</p>
{reservation.status ===
'pending_approval' &&
candidates.length >
0 && (
<div className="flex gap-2">
<select
value={
replacements[
item
.id
] ??
''
}
onChange={(
event,
) =>
setReplacements(
(
current,
) => ({
...current,
[item.id]:
event
.target
.value,
}),
)
}
className="min-w-0 flex-1 rounded-md border bg-transparent px-2 py-1 text-xs"
>
<option value="">
Substituir
por…
</option>
{candidates.map(
(
asset,
) => (
<option
key={
asset.id
}
value={
asset.id
}
>
{
asset.name
}{' '}
—{' '}
{
asset.asset_tag
}
</option>
),
)}
</select>
<Button
type="button"
size="sm"
variant="outline"
disabled={
!replacements[
item
.id
]
}
onClick={() =>
substitute(
reservation,
item.id,
)
}
>
<RefreshCw />
</Button>
</div>
)}
</div>
);
})}
</div>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground uppercase"> <p className="text-xs text-muted-foreground uppercase">
@@ -175,6 +360,38 @@ export default function ApprovalsIndex({
A aguardar resposta do requisitante. A aguardar resposta do requisitante.
</p> </p>
)} )}
{reservation.approval_actions.length > 0 && (
<details className="rounded-lg border p-3 text-sm">
<summary className="cursor-pointer font-medium">
Histórico de aprovação (
{
reservation.approval_actions
.length
}
)
</summary>
<div className="mt-3 space-y-3 border-t pt-3">
{reservation.approval_actions.map(
(action) => (
<div key={action.id}>
<p className="font-medium">
{actionLabel(
action.action,
)}{' '}
·{' '}
{action.actor.name}
</p>
{action.comment && (
<p className="mt-1 text-muted-foreground">
{action.comment}
</p>
)}
</div>
),
)}
</div>
</details>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -185,6 +402,35 @@ export default function ApprovalsIndex({
</div> </div>
)} )}
</section> </section>
{reservations.last_page > 1 && (
<nav className="flex justify-center gap-2">
{reservations.links.map((link, index) => (
<Button
key={index}
asChild={Boolean(link.url)}
disabled={!link.url}
variant={link.active ? 'default' : 'outline'}
size="sm"
>
{link.url ? (
<Link
href={link.url}
preserveScroll
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
) : (
<span
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
)}
</Button>
))}
</nav>
)}
</main> </main>
</> </>
); );
@@ -199,6 +445,18 @@ function formatPeriod(reservation: Reservation): string {
return `${format.format(new Date(reservation.starts_at))} — ${format.format(new Date(reservation.ends_at))}`; return `${format.format(new Date(reservation.starts_at))} — ${format.format(new Date(reservation.ends_at))}`;
} }
function actionLabel(action: string): string {
return (
{
approved: 'Aprovada',
rejected: 'Rejeitada',
information_requested: 'Esclarecimentos pedidos',
information_provided: 'Esclarecimentos recebidos',
asset_substituted: 'Equipamento substituído',
}[action] ?? action
);
}
ApprovalsIndex.layout = { ApprovalsIndex.layout = {
breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }], breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }],
}; };

View File

@@ -0,0 +1,123 @@
import { Head, Link, router } from '@inertiajs/react';
import { Bell, CheckCheck } from 'lucide-react';
import { PageHeading } from '@/components/page-heading';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Paginated } from '@/types';
type PortalNotification = {
id: string;
data: {
message: string;
url?: string;
reference?: string;
};
read_at: string | null;
created_at: string;
};
export default function NotificationsIndex({
notifications,
}: {
notifications: Paginated<PortalNotification>;
}) {
function openNotification(notification: PortalNotification) {
router.post(`/notificacoes/${notification.id}/ler`);
}
return (
<>
<Head title="Notificações" />
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Caixa interna"
title="Notificações"
description="Atualizações sobre requisições, decisões e pedidos de esclarecimento."
action={
<Button
variant="outline"
onClick={() =>
router.post('/notificacoes/ler-todas')
}
>
<CheckCheck /> Marcar todas como lidas
</Button>
}
/>
<section className="overflow-hidden rounded-xl border bg-card">
{notifications.data.map((notification) => (
<button
type="button"
key={notification.id}
onClick={() => openNotification(notification)}
className={cn(
'flex w-full gap-4 border-b p-5 text-left last:border-0 hover:bg-slate-50 dark:hover:bg-slate-900',
notification.read_at === null && 'bg-primary/5',
)}
>
<span className="mt-0.5 rounded-full bg-primary/10 p-2 text-primary">
<Bell className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{notification.data.message}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(
new Date(notification.created_at),
)}
</p>
</div>
{notification.read_at === null && (
<span className="mt-2 size-2 rounded-full bg-primary" />
)}
</button>
))}
{notifications.data.length === 0 && (
<div className="py-16 text-center text-sm text-muted-foreground">
Não existem notificações.
</div>
)}
</section>
{notifications.last_page > 1 && (
<nav className="flex justify-center gap-2">
{notifications.links.map((link, index) => (
<Button
key={index}
asChild={Boolean(link.url)}
disabled={!link.url}
variant={link.active ? 'default' : 'outline'}
size="sm"
>
{link.url ? (
<Link
href={link.url}
preserveScroll
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
) : (
<span
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
)}
</Button>
))}
</nav>
)}
</main>
</>
);
}
NotificationsIndex.layout = {
breadcrumbs: [{ title: 'Notificações', href: '/notificacoes' }],
};

View File

@@ -1,5 +1,5 @@
import { Head, Link, useForm } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Ban, CalendarDays, Plus } from 'lucide-react'; import { Ban, CalendarDays, MessageSquareText, Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading'; import { PageHeading } from '@/components/page-heading';
@@ -22,7 +22,11 @@ export default function ReservationsIndex({
reservations: Paginated<Reservation>; reservations: Paginated<Reservation>;
}) { }) {
const [selected, setSelected] = useState<Reservation | null>(null); const [selected, setSelected] = useState<Reservation | null>(null);
const [clarification, setClarification] = useState<Reservation | null>(
null,
);
const cancelForm = useForm({ reason: '' }); const cancelForm = useForm({ reason: '' });
const clarificationForm = useForm({ response: '' });
const format = new Intl.DateTimeFormat('pt-PT', { const format = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
@@ -54,6 +58,25 @@ export default function ReservationsIndex({
}); });
} }
function openClarification(reservation: Reservation) {
clarificationForm.reset();
clarificationForm.clearErrors();
setClarification(reservation);
}
function submitClarification(event: React.FormEvent) {
event.preventDefault();
if (!clarification) {
return;
}
clarificationForm.post(`/requisicoes/${clarification.uuid}/responder`, {
preserveScroll: true,
onSuccess: () => setClarification(null),
});
}
return ( return (
<> <>
<Head title="As minhas requisições" /> <Head title="As minhas requisições" />
@@ -87,7 +110,20 @@ export default function ReservationsIndex({
<span className="font-mono text-sm font-medium"> <span className="font-mono text-sm font-medium">
{reservation.reference} {reservation.reference}
</span> </span>
<div> <div className="flex flex-col items-start gap-1">
{reservation.status ===
'awaiting_information' && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
openClarification(reservation)
}
>
<MessageSquareText /> Responder
</Button>
)}
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{reservation.items {reservation.items
.map((item) => item.asset.name) .map((item) => item.asset.name)
@@ -232,6 +268,61 @@ export default function ReservationsIndex({
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={clarification !== null}
onOpenChange={(open) => !open && setClarification(null)}
>
<DialogContent>
<form onSubmit={submitClarification}>
<DialogHeader>
<DialogTitle>
Prestar esclarecimentos
</DialogTitle>
<DialogDescription>
{clarification?.decision_reason ??
'O aprovador pediu informação adicional para poder decidir.'}
</DialogDescription>
</DialogHeader>
<div className="my-5">
<Label htmlFor="clarification-response">
Resposta
</Label>
<textarea
id="clarification-response"
value={clarificationForm.data.response}
onChange={(event) =>
clarificationForm.setData(
'response',
event.target.value,
)
}
rows={5}
className="mt-2 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoFocus
/>
<InputError
message={clarificationForm.errors.response}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setClarification(null)}
>
Voltar
</Button>
<Button
type="submit"
disabled={clarificationForm.processing}
>
<MessageSquareText /> Enviar resposta
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main> </main>
</> </>
); );

View File

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

View File

@@ -4,6 +4,7 @@ export type Asset = {
asset_tag: string; asset_tag: string;
model_name: string | null; model_name: string | null;
category_name: string | null; category_name: string | null;
category_id?: number | null;
location_name: string | null; location_name: string | null;
status_name?: string | null; status_name?: string | null;
image_url?: string | null; image_url?: string | null;
@@ -11,7 +12,11 @@ export type Asset = {
export type ReservationItem = { export type ReservationItem = {
id: number; id: number;
asset: Pick<Asset, 'id' | 'name' | 'asset_tag' | 'category_name'>; status: string;
asset: Pick<
Asset,
'id' | 'name' | 'asset_tag' | 'category_id' | 'category_name'
>;
}; };
export type Reservation = { export type Reservation = {
@@ -23,6 +28,7 @@ export type Reservation = {
starts_at: string; starts_at: string;
ends_at: string; ends_at: string;
status: string; status: string;
decision_reason?: string | null;
items: ReservationItem[]; items: ReservationItem[];
}; };

View File

@@ -4,6 +4,7 @@ use App\Http\Controllers\Admin\SnipeItController;
use App\Http\Controllers\ApprovalController; use App\Http\Controllers\ApprovalController;
use App\Http\Controllers\AssetController; use App\Http\Controllers\AssetController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ReservationController; use App\Http\Controllers\ReservationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -12,17 +13,22 @@ Route::get('/', fn () => redirect()->route(auth()->check() ? 'dashboard' : 'logi
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard'); Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('equipamentos', [AssetController::class, 'index'])->name('assets.index'); Route::get('equipamentos', [AssetController::class, 'index'])->name('assets.index');
Route::get('notificacoes', [NotificationController::class, 'index'])->name('notifications.index');
Route::post('notificacoes/ler-todas', [NotificationController::class, 'readAll'])->name('notifications.read-all');
Route::post('notificacoes/{notification}/ler', [NotificationController::class, 'read'])->name('notifications.read');
Route::get('requisicoes', [ReservationController::class, 'index'])->name('reservations.index'); Route::get('requisicoes', [ReservationController::class, 'index'])->name('reservations.index');
Route::get('requisicoes/calendario', [ReservationController::class, 'calendar'])->name('reservations.calendar'); Route::get('requisicoes/calendario', [ReservationController::class, 'calendar'])->name('reservations.calendar');
Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create'); Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create');
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store'); Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel'); Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
Route::post('requisicoes/{reservation:uuid}/responder', [ReservationController::class, 'respondToInformation'])->name('reservations.respond');
Route::middleware('can:approve-reservations')->prefix('aprovacoes')->name('approvals.')->group(function () { Route::middleware('can:approve-reservations')->prefix('aprovacoes')->name('approvals.')->group(function () {
Route::get('/', [ApprovalController::class, 'index'])->name('index'); Route::get('/', [ApprovalController::class, 'index'])->name('index');
Route::post('{reservation:uuid}/aprovar', [ApprovalController::class, 'approve'])->name('approve'); Route::post('{reservation:uuid}/aprovar', [ApprovalController::class, 'approve'])->name('approve');
Route::post('{reservation:uuid}/rejeitar', [ApprovalController::class, 'reject'])->name('reject'); Route::post('{reservation:uuid}/rejeitar', [ApprovalController::class, 'reject'])->name('reject');
Route::post('{reservation:uuid}/pedir-esclarecimentos', [ApprovalController::class, 'requestInformation'])->name('request-information'); Route::post('{reservation:uuid}/pedir-esclarecimentos', [ApprovalController::class, 'requestInformation'])->name('request-information');
Route::post('{reservation:uuid}/itens/{item}/substituir', [ApprovalController::class, 'substitute'])->name('substitute');
}); });
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () { Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {

View File

@@ -7,7 +7,11 @@ use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation; use App\Domain\Reservations\Models\Reservation;
use App\Models\User; use App\Models\User;
use App\Notifications\ReservationDecisionNotification;
use App\Notifications\ReservationSubmittedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -43,6 +47,7 @@ function approverUser(): User
} }
it('aprova uma requisição após revalidar disponibilidade', function () { it('aprova uma requisição após revalidar disponibilidade', function () {
Notification::fake();
$approver = approverUser(); $approver = approverUser();
$reservation = pendingApprovalReservation( $reservation = pendingApprovalReservation(
User::factory()->create(), User::factory()->create(),
@@ -63,9 +68,15 @@ it('aprova uma requisição após revalidar disponibilidade', function () {
'action' => 'approved', 'action' => 'approved',
]); ]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.approved']); $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.approved']);
Notification::assertSentTo(
$reservation->requester,
ReservationDecisionNotification::class,
fn (ReservationDecisionNotification $notification): bool => $notification->event === 'approved',
);
}); });
it('rejeita uma requisição e liberta a pré-reserva', function () { it('rejeita uma requisição e liberta a pré-reserva', function () {
Notification::fake();
$approver = approverUser(); $approver = approverUser();
$reservation = pendingApprovalReservation( $reservation = pendingApprovalReservation(
User::factory()->create(), User::factory()->create(),
@@ -79,9 +90,11 @@ it('rejeita uma requisição e liberta a pré-reserva', function () {
expect($reservation->fresh()->status)->toBe(ReservationStatus::Rejected) expect($reservation->fresh()->status)->toBe(ReservationStatus::Rejected)
->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Cancelled); ->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Cancelled);
Notification::assertSentTo($reservation->requester, ReservationDecisionNotification::class);
}); });
it('regista um pedido de esclarecimentos e renova o prazo', function () { it('regista um pedido de esclarecimentos e renova o prazo', function () {
Notification::fake();
$approver = approverUser(); $approver = approverUser();
$reservation = pendingApprovalReservation( $reservation = pendingApprovalReservation(
User::factory()->create(), User::factory()->create(),
@@ -99,6 +112,29 @@ it('regista um pedido de esclarecimentos e renova o prazo', function () {
expect($reservation->fresh()->status)->toBe(ReservationStatus::AwaitingInformation) expect($reservation->fresh()->status)->toBe(ReservationStatus::AwaitingInformation)
->and($reservation->fresh()->approval_deadline->isAfter($oldDeadline))->toBeTrue(); ->and($reservation->fresh()->approval_deadline->isAfter($oldDeadline))->toBeTrue();
$this->assertDatabaseHas('approval_actions', ['action' => 'information_requested']); $this->assertDatabaseHas('approval_actions', ['action' => 'information_requested']);
Notification::assertSentTo(
$reservation->requester,
ReservationDecisionNotification::class,
fn (ReservationDecisionNotification $notification): bool => $notification->event === 'information_requested',
);
$this->actingAs($reservation->requester)
->post("/requisicoes/{$reservation->uuid}/responder", [
'response' => 'A docente responsável fará o transporte em caixa protegida.',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval);
$this->assertDatabaseHas('approval_actions', [
'reservation_id' => $reservation->id,
'action' => 'information_provided',
]);
Notification::assertSentTo(
$approver,
ReservationSubmittedNotification::class,
fn (ReservationSubmittedNotification $notification): bool => $notification->event === 'information_provided',
);
}); });
it('impede requisitantes de aceder à área de aprovação', function () { it('impede requisitantes de aceder à área de aprovação', function () {
@@ -119,3 +155,96 @@ it('recusa aprovação se o estado operacional do ativo mudou', function () {
expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval); expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval);
}); });
it('substitui um equipamento por outro disponível da mesma categoria', function () {
Notification::fake();
$approver = approverUser();
$original = SnipeAsset::factory()->create(['category_id' => 7, 'category_name' => 'Vídeo']);
$replacement = SnipeAsset::factory()->create(['category_id' => 7, 'category_name' => 'Vídeo']);
$reservation = pendingApprovalReservation(User::factory()->create(), $original, 'REQ-SWAP-001');
$item = $reservation->items()->firstOrFail();
$this->actingAs($approver)
->post("/aprovacoes/{$reservation->uuid}/itens/{$item->id}/substituir", [
'replacement_asset_id' => $replacement->id,
'comment' => 'O modelo alternativo tem a ligação necessária.',
])
->assertRedirect()
->assertSessionHas('success');
expect($item->fresh()->status)->toBe(ReservationItemStatus::Cancelled);
$this->assertDatabaseHas('reservation_items', [
'reservation_id' => $reservation->id,
'snipe_asset_id' => $replacement->id,
'status' => ReservationItemStatus::Held->value,
]);
$this->assertDatabaseHas('approval_actions', ['action' => 'asset_substituted']);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.asset_substituted']);
Notification::assertSentTo(
$reservation->requester,
ReservationDecisionNotification::class,
fn (ReservationDecisionNotification $notification): bool => $notification->event === 'asset_substituted',
);
});
it('recusa substituição por equipamento de outra categoria', function () {
$approver = approverUser();
$original = SnipeAsset::factory()->create(['category_id' => 7, 'category_name' => 'Vídeo']);
$replacement = SnipeAsset::factory()->create(['category_id' => 8, 'category_name' => 'Áudio']);
$reservation = pendingApprovalReservation(User::factory()->create(), $original, 'REQ-SWAP-002');
$item = $reservation->items()->firstOrFail();
$this->actingAs($approver)
->from('/aprovacoes')
->post("/aprovacoes/{$reservation->uuid}/itens/{$item->id}/substituir", [
'replacement_asset_id' => $replacement->id,
'comment' => 'Tentativa de troca entre categorias diferentes.',
])
->assertRedirect('/aprovacoes')
->assertSessionHasErrors('replacement_asset_id');
expect($item->fresh()->status)->toBe(ReservationItemStatus::Held);
});
it('filtra a fila de aprovação por categoria e período', function () {
$approver = approverUser();
$requester = User::factory()->create();
pendingApprovalReservation(
$requester,
SnipeAsset::factory()->create(['category_name' => 'Projetores']),
'REQ-FILTER-PROJECTOR',
);
pendingApprovalReservation(
$requester,
SnipeAsset::factory()->create(['category_name' => 'Áudio']),
'REQ-FILTER-AUDIO',
);
$query = http_build_query([
'category' => 'Projetores',
'date_from' => now()->addDays(2)->toDateString(),
'date_to' => now()->addDays(4)->toDateString(),
]);
$this->actingAs($approver)
->get("/aprovacoes?{$query}")
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('approvals/index')
->has('reservations.data', 1)
->where('reservations.data.0.reference', 'REQ-FILTER-PROJECTOR'));
});
it('impede outro utilizador de responder aos esclarecimentos', function () {
$reservation = pendingApprovalReservation(
User::factory()->create(),
SnipeAsset::factory()->create(),
'REQ-INFO-IDOR',
);
$reservation->update(['status' => ReservationStatus::AwaitingInformation]);
$this->actingAs(User::factory()->create())
->post("/requisicoes/{$reservation->uuid}/responder", [
'response' => 'Tentativa de resposta por outro utilizador.',
])
->assertForbidden();
});

View File

@@ -0,0 +1,45 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
it('apresenta e marca como lidas apenas as notificações do utilizador', function () {
$user = User::factory()->create();
$other = User::factory()->create();
$notificationId = (string) Str::uuid();
$otherNotificationId = (string) Str::uuid();
foreach ([[$notificationId, $user], [$otherNotificationId, $other]] as [$id, $recipient]) {
DB::table('notifications')->insert([
'id' => $id,
'type' => 'test',
'notifiable_type' => $recipient->getMorphClass(),
'notifiable_id' => $recipient->id,
'data' => json_encode(['message' => 'Atualização de teste.', 'url' => '/requisicoes']),
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->actingAs($user)
->get('/notificacoes')
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('notifications/index')
->has('notifications.data', 1)
->where('notifications.data.0.id', $notificationId));
$this->post("/notificacoes/{$notificationId}/ler")
->assertRedirect('/requisicoes');
$this->assertDatabaseMissing('notifications', [
'id' => $notificationId,
'read_at' => null,
]);
$this->post("/notificacoes/{$otherNotificationId}/ler")->assertNotFound();
});

View File

@@ -1,15 +1,22 @@
<?php <?php
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role;
use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Enums\ReservationStatus;
use App\Models\User; use App\Models\User;
use App\Notifications\ReservationSubmittedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('cria uma pré-reserva auditada com vários equipamentos', function () { it('cria uma pré-reserva auditada com vários equipamentos', function () {
Notification::fake();
$user = User::factory()->create(); $user = User::factory()->create();
$approver = User::factory()->create();
$approver->roles()->attach(Role::create(['name' => RoleName::Approver->value]));
$assets = SnipeAsset::factory()->count(2)->create(); $assets = SnipeAsset::factory()->count(2)->create();
$startsAt = now()->addDays(2)->startOfHour(); $startsAt = now()->addDays(2)->startOfHour();
$endsAt = $startsAt->addHours(3); $endsAt = $startsAt->addHours(3);
@@ -31,6 +38,8 @@ it('cria uma pré-reserva auditada com vários equipamentos', function () {
$this->assertDatabaseCount('reservation_items', 2); $this->assertDatabaseCount('reservation_items', 2);
$this->assertDatabaseHas('reservation_items', ['status' => ReservationItemStatus::Held->value]); $this->assertDatabaseHas('reservation_items', ['status' => ReservationItemStatus::Held->value]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.submitted', 'actor_id' => $user->id]); $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.submitted', 'actor_id' => $user->id]);
Notification::assertSentTo($approver, ReservationSubmittedNotification::class);
Notification::assertNotSentTo($user, ReservationSubmittedNotification::class);
}); });
it('recusa um período que conflita com uma pré-reserva', function () { it('recusa um período que conflita com uma pré-reserva', function () {