feat: complete approval workflow
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -42,7 +47,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 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.
|
||||
- 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”.
|
||||
@@ -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.
|
||||
- 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.
|
||||
- Migration da caixa interna de notificações aplicada em PostgreSQL 17.
|
||||
|
||||
@@ -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**. 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.
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ 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\Models\ReservationItem;
|
||||
use App\Domain\Reservations\Services\AvailabilityService;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ReservationDecisionNotification;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -23,7 +25,7 @@ class ApprovalService
|
||||
{
|
||||
$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);
|
||||
$items = $locked->items()->with('asset')->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');
|
||||
}, 3);
|
||||
|
||||
$approved->requester->notify(new ReservationDecisionNotification($approved, 'approved', $comment));
|
||||
|
||||
return $approved;
|
||||
}
|
||||
|
||||
public function reject(Reservation $reservation, User $actor, string $reason): Reservation
|
||||
{
|
||||
$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);
|
||||
$this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [
|
||||
'rejected_by' => $actor->id,
|
||||
@@ -70,13 +76,17 @@ class ApprovalService
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 3);
|
||||
|
||||
$rejected->requester->notify(new ReservationDecisionNotification($rejected, 'rejected', $reason));
|
||||
|
||||
return $rejected;
|
||||
}
|
||||
|
||||
public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation
|
||||
{
|
||||
$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);
|
||||
$this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [
|
||||
'decision_reason' => $comment,
|
||||
@@ -85,6 +95,81 @@ class ApprovalService
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 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
|
||||
@@ -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
|
||||
{
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domain\Reservations\Models;
|
||||
|
||||
use App\Domain\Approvals\Models\ApprovalAction;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -57,4 +58,10 @@ class Reservation extends Model
|
||||
{
|
||||
return $this->hasMany(ReservationItem::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ApprovalAction, $this> */
|
||||
public function approvalActions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalAction::class)->latest('created_at');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
namespace App\Domain\Reservations\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\Models\User;
|
||||
use App\Notifications\ReservationSubmittedNotification;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -26,7 +30,7 @@ class ReservationService
|
||||
$startsAt = CarbonImmutable::parse($data['starts_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()
|
||||
->whereKey($data['asset_ids'])
|
||||
->lockForUpdate()
|
||||
@@ -75,8 +79,19 @@ class ReservationService
|
||||
'metadata' => ['asset_ids' => $assets->modelKeys()],
|
||||
]);
|
||||
|
||||
return $reservation->load('items.asset');
|
||||
return $reservation->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($reservation));
|
||||
|
||||
return $reservation;
|
||||
}
|
||||
|
||||
public function cancel(Reservation $reservation, User $actor, string $reason): Reservation
|
||||
@@ -124,6 +139,61 @@ class ReservationService
|
||||
}, 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
|
||||
{
|
||||
$reservationIds = Reservation::query()
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Approvals\Services\ApprovalService;
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
@@ -14,20 +17,46 @@ class ApprovalController extends Controller
|
||||
{
|
||||
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', [
|
||||
'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])
|
||||
->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}%"))))
|
||||
->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')
|
||||
->paginate(20)
|
||||
->withQueryString(),
|
||||
'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}.");
|
||||
}
|
||||
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Http/Controllers/NotificationController.php
Normal file
35
app/Http/Controllers/NotificationController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -95,4 +95,14 @@ class ReservationController extends Controller
|
||||
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? 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',
|
||||
];
|
||||
}
|
||||
|
||||
77
app/Notifications/ReservationDecisionNotification.php
Normal file
77
app/Notifications/ReservationDecisionNotification.php
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
55
app/Notifications/ReservationSubmittedNotification.php
Normal file
55
app/Notifications/ReservationSubmittedNotification.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -32,7 +32,6 @@ Nginx ──► Laravel + Inertia/React
|
||||
- `app/Domain/Identity`: perfis, permissões e identidades externas.
|
||||
- `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.
|
||||
@@ -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.
|
||||
|
||||
`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
|
||||
|
||||
1. Um job consulta a API paginada com token de leitura.
|
||||
|
||||
@@ -62,3 +62,10 @@ Uma requisição submetida cria itens em `held` e bloqueia o período durante 48
|
||||
**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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 4 em curso
|
||||
**Fase:** Fases 1, 2, 3 e 4 concluídas
|
||||
**Último agente:** Codex
|
||||
|
||||
## Resultado disponível
|
||||
@@ -29,26 +29,31 @@
|
||||
- Á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`.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa.
|
||||
|
||||
## Próxima tarefa recomendada
|
||||
|
||||
Completar a **Fase 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;
|
||||
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;
|
||||
1. abrir `app/Integrations/SnipeIt/SnipeItClient.php` e acrescentar checkout/check-in com o token operacional separado;
|
||||
2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais;
|
||||
3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico;
|
||||
4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída;
|
||||
5. implementar incidentes, atrasos e bloqueios associados a dano;
|
||||
6. testar API simulada, autorização, idempotência e auditoria;
|
||||
7. atualizar este documento e o changelog.
|
||||
|
||||
## 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:
|
||||
|
||||
- `docker compose exec -T app composer test`: passou;
|
||||
- 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;
|
||||
- Laravel Pint: 111 ficheiros sem desvios;
|
||||
- PHPStan/Larastan nível 7: 84 ficheiros, zero erros;
|
||||
- Pest: 70 testes passaram, 340 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, 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: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
|
||||
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
|
||||
- PostgreSQL 17: migration `notifications` aplicada com sucesso;
|
||||
- smoke test HTTP: `/login` respondeu com estado 200 através do Nginx;
|
||||
- Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`;
|
||||
- Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia;
|
||||
- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
Antes de disponibilizar:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Bell,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
LayoutGrid,
|
||||
@@ -42,6 +43,7 @@ const mainNavItems: NavItem[] = [
|
||||
href: '/requisicoes/calendario',
|
||||
icon: CalendarRange,
|
||||
},
|
||||
{ title: 'Notificações', href: '/notificacoes', icon: Bell },
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
@@ -53,17 +55,22 @@ const footerNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
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
|
||||
? [
|
||||
...mainNavItems,
|
||||
...userNavItems,
|
||||
{
|
||||
title: 'Aprovações',
|
||||
href: '/aprovacoes',
|
||||
icon: ListChecks,
|
||||
},
|
||||
]
|
||||
: mainNavItems;
|
||||
: userNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
? [
|
||||
...roleNavItems,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Head, router, usePage } from '@inertiajs/react';
|
||||
import { Check, CircleHelp, Search, X } from 'lucide-react';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Check, CircleHelp, RefreshCw, Search, X } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
@@ -8,7 +8,7 @@ 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';
|
||||
import type { Asset, Paginated, Reservation } from '@/types';
|
||||
|
||||
type ApprovalReservation = Reservation & {
|
||||
requester: {
|
||||
@@ -16,22 +16,49 @@ type ApprovalReservation = Reservation & {
|
||||
email: string;
|
||||
department: string | null;
|
||||
};
|
||||
approval_actions: {
|
||||
id: number;
|
||||
action: string;
|
||||
comment: string | null;
|
||||
created_at: string;
|
||||
actor: { name: string };
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function ApprovalsIndex({
|
||||
reservations,
|
||||
filters,
|
||||
replacementAssets,
|
||||
categories,
|
||||
}: {
|
||||
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 [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 [replacements, setReplacements] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
const { errors } = usePage().props;
|
||||
|
||||
function submitSearch(event: FormEvent) {
|
||||
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) {
|
||||
@@ -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 (
|
||||
<>
|
||||
<Head title="Aprovações" />
|
||||
@@ -51,8 +89,11 @@ export default function ApprovalsIndex({
|
||||
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">
|
||||
<form
|
||||
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" />
|
||||
<Input
|
||||
value={search}
|
||||
@@ -61,6 +102,30 @@ export default function ApprovalsIndex({
|
||||
className="pl-9"
|
||||
/>
|
||||
</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">
|
||||
Pesquisar
|
||||
</Button>
|
||||
@@ -90,11 +155,131 @@ export default function ApprovalsIndex({
|
||||
<p className="text-xs text-muted-foreground uppercase">
|
||||
Equipamentos
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
<div className="mt-1 space-y-2">
|
||||
{reservation.items
|
||||
.map((item) => item.asset.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
.filter(
|
||||
(item) =>
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground uppercase">
|
||||
@@ -175,6 +360,38 @@ export default function ApprovalsIndex({
|
||||
A aguardar resposta do requisitante.
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
@@ -185,6 +402,35 @@ export default function ApprovalsIndex({
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
@@ -199,6 +445,18 @@ function formatPeriod(reservation: Reservation): string {
|
||||
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 = {
|
||||
breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }],
|
||||
};
|
||||
|
||||
123
resources/js/pages/notifications/index.tsx
Normal file
123
resources/js/pages/notifications/index.tsx
Normal 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' }],
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
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 InputError from '@/components/input-error';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
@@ -22,7 +22,11 @@ export default function ReservationsIndex({
|
||||
reservations: Paginated<Reservation>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Reservation | null>(null);
|
||||
const [clarification, setClarification] = useState<Reservation | null>(
|
||||
null,
|
||||
);
|
||||
const cancelForm = useForm({ reason: '' });
|
||||
const clarificationForm = useForm({ response: '' });
|
||||
const format = new Intl.DateTimeFormat('pt-PT', {
|
||||
dateStyle: 'medium',
|
||||
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 (
|
||||
<>
|
||||
<Head title="As minhas requisições" />
|
||||
@@ -87,7 +110,20 @@ export default function ReservationsIndex({
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{reservation.reference}
|
||||
</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">
|
||||
{reservation.items
|
||||
.map((item) => item.asset.name)
|
||||
@@ -232,6 +268,61 @@ export default function ReservationsIndex({
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -20,6 +20,7 @@ declare module '@inertiajs/core' {
|
||||
administer: boolean;
|
||||
approveReservations: boolean;
|
||||
};
|
||||
unreadNotifications: number;
|
||||
sidebarOpen: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export type Asset = {
|
||||
asset_tag: string;
|
||||
model_name: string | null;
|
||||
category_name: string | null;
|
||||
category_id?: number | null;
|
||||
location_name: string | null;
|
||||
status_name?: string | null;
|
||||
image_url?: string | null;
|
||||
@@ -11,7 +12,11 @@ export type Asset = {
|
||||
|
||||
export type ReservationItem = {
|
||||
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 = {
|
||||
@@ -23,6 +28,7 @@ export type Reservation = {
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
status: string;
|
||||
decision_reason?: string | null;
|
||||
items: ReservationItem[];
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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\NotificationController;
|
||||
use App\Http\Controllers\ReservationController;
|
||||
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::get('dashboard', DashboardController::class)->name('dashboard');
|
||||
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/calendario', [ReservationController::class, 'calendar'])->name('reservations.calendar');
|
||||
Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create');
|
||||
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}/responder', [ReservationController::class, 'respondToInformation'])->name('reservations.respond');
|
||||
|
||||
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::post('{reservation:uuid}/itens/{item}/substituir', [ApprovalController::class, 'substitute'])->name('substitute');
|
||||
});
|
||||
|
||||
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
||||
|
||||
@@ -7,7 +7,11 @@ use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ReservationDecisionNotification;
|
||||
use App\Notifications\ReservationSubmittedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -43,6 +47,7 @@ function approverUser(): User
|
||||
}
|
||||
|
||||
it('aprova uma requisição após revalidar disponibilidade', function () {
|
||||
Notification::fake();
|
||||
$approver = approverUser();
|
||||
$reservation = pendingApprovalReservation(
|
||||
User::factory()->create(),
|
||||
@@ -63,9 +68,15 @@ it('aprova uma requisição após revalidar disponibilidade', function () {
|
||||
'action' => '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 () {
|
||||
Notification::fake();
|
||||
$approver = approverUser();
|
||||
$reservation = pendingApprovalReservation(
|
||||
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)
|
||||
->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 () {
|
||||
Notification::fake();
|
||||
$approver = approverUser();
|
||||
$reservation = pendingApprovalReservation(
|
||||
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)
|
||||
->and($reservation->fresh()->approval_deadline->isAfter($oldDeadline))->toBeTrue();
|
||||
$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 () {
|
||||
@@ -119,3 +155,96 @@ it('recusa aprovação se o estado operacional do ativo mudou', function () {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
45
tests/Feature/NotificationCenterTest.php
Normal file
45
tests/Feature/NotificationCenterTest.php
Normal 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();
|
||||
});
|
||||
@@ -1,15 +1,22 @@
|
||||
<?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\Models\User;
|
||||
use App\Notifications\ReservationSubmittedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('cria uma pré-reserva auditada com vários equipamentos', function () {
|
||||
Notification::fake();
|
||||
$user = User::factory()->create();
|
||||
$approver = User::factory()->create();
|
||||
$approver->roles()->attach(Role::create(['name' => RoleName::Approver->value]));
|
||||
$assets = SnipeAsset::factory()->count(2)->create();
|
||||
$startsAt = now()->addDays(2)->startOfHour();
|
||||
$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->assertDatabaseHas('reservation_items', ['status' => ReservationItemStatus::Held->value]);
|
||||
$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 () {
|
||||
|
||||
Reference in New Issue
Block a user