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.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.");
|
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,
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
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/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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
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>
|
</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' }],
|
||||||
};
|
};
|
||||||
|
|||||||
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 { 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
administer: boolean;
|
||||||
approveReservations: boolean;
|
approveReservations: boolean;
|
||||||
};
|
};
|
||||||
|
unreadNotifications: number;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
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
|
<?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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user