feat: complete administration and reporting
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -35,6 +35,13 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
|||||||
- Associação do utilizador local ao ID Snipe-IT confirmada durante a entrega.
|
- Associação do utilizador local ao ID Snipe-IT confirmada durante a entrega.
|
||||||
- Bloqueios temporais de equipamento criados a partir de incidentes e considerados pelo motor de disponibilidade.
|
- Bloqueios temporais de equipamento criados a partir de incidentes e considerados pelo motor de disponibilidade.
|
||||||
- Tratamento e auditoria de falhas parciais em movimentos com vários equipamentos.
|
- Tratamento e auditoria de falhas parciais em movimentos com vários equipamentos.
|
||||||
|
- Gestão administrativa de utilizadores, perfis, estado, departamento, associação Snipe-IT e palavra-passe.
|
||||||
|
- Regras de reserva editáveis com antecedência, duração, margem, prazo de aprovação e limite de equipamentos.
|
||||||
|
- Buffers temporais por item e restrição GiST atualizada para garantir margens também sob concorrência.
|
||||||
|
- Calendário global com identificação do requisitante e autorização por perfil.
|
||||||
|
- Relatórios operacionais, métricas por dimensão e exportação CSV detalhada.
|
||||||
|
- Consulta de auditoria filtrável e paginada para administradores e auditores.
|
||||||
|
- Modelos editáveis para email e notificações internas com variáveis funcionais.
|
||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
|
|
||||||
@@ -45,12 +52,16 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
|||||||
- SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite.
|
- SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite.
|
||||||
- Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo.
|
- Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo.
|
||||||
- Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução.
|
- Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução.
|
||||||
|
- Formulário de requisição apresenta as políticas atuais e o serviço de domínio volta a validá-las antes da transação.
|
||||||
|
- Notificações passam a obter assunto e mensagem dos modelos administrativos, preservando defaults seguros.
|
||||||
|
|
||||||
### Segurança
|
### Segurança
|
||||||
|
|
||||||
- Registo público desativado.
|
- Registo público desativado.
|
||||||
- Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores.
|
- Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores.
|
||||||
- Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático.
|
- Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático.
|
||||||
|
- Exportação CSV neutraliza células iniciadas por caracteres de fórmula e limita cada relatório a 366 dias.
|
||||||
|
- Gestão administrativa impede auto-desativação e garante pelo menos um administrador ativo.
|
||||||
|
|
||||||
### Verificado
|
### Verificado
|
||||||
|
|
||||||
@@ -65,3 +76,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
|||||||
- 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.
|
- Migration da caixa interna de notificações aplicada em PostgreSQL 17.
|
||||||
- Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada.
|
- Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada.
|
||||||
|
- Administração: utilizadores, políticas, buffers, calendário global, relatórios, CSV, auditoria e modelos de notificação validados por testes funcionais.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
|
|||||||
|
|
||||||
## Estado atual
|
## Estado atual
|
||||||
|
|
||||||
O projeto concluiu as **Fases 1 a 5**: fundação técnica, integração Snipe-IT, motor de reservas, aprovações e operações físicas. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, calendário pessoal, decisões e notificações, checkout/check-in Snipe-IT, área diária do operador, entrega/devolução auditada, incidentes, atrasos e bloqueios. A próxima entrega é a administração e os relatórios.
|
O projeto concluiu as **Fases 1 a 6**: fundação técnica, integração Snipe-IT, reservas, aprovações, operações físicas e administração/relatórios. Inclui gestão de utilizadores e perfis, regras editáveis com margens garantidas em PostgreSQL, calendário global, métricas, exportação CSV, auditoria e modelos de email. A próxima entrega é o hardening e a entrada em produçã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.
|
||||||
|
|
||||||
|
|||||||
29
app/Domain/Administration/Models/PortalSetting.php
Normal file
29
app/Domain/Administration/Models/PortalSetting.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Administration\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PortalSetting extends Model
|
||||||
|
{
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'key';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = ['key', 'value', 'description', 'updated_by'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['value' => 'json'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function updatedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'updated_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Administration\Services;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Models\PortalSetting;
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class NotificationTemplateService
|
||||||
|
{
|
||||||
|
/** @return array<string, array{label:string,subject:string,message:string}> */
|
||||||
|
public function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'submitted' => [
|
||||||
|
'label' => 'Nova requisição',
|
||||||
|
'subject' => 'Nova requisição {reference}',
|
||||||
|
'message' => '{requester} submeteu {reference} para decisão. Período: {starts_at} — {ends_at}.',
|
||||||
|
],
|
||||||
|
'information_provided' => [
|
||||||
|
'label' => 'Esclarecimentos prestados',
|
||||||
|
'subject' => 'Resposta recebida para {reference}',
|
||||||
|
'message' => '{requester} prestou esclarecimentos para {reference}.',
|
||||||
|
],
|
||||||
|
'approved' => [
|
||||||
|
'label' => 'Requisição aprovada',
|
||||||
|
'subject' => 'Requisição {reference} aprovada',
|
||||||
|
'message' => 'A sua requisição {reference} foi aprovada.',
|
||||||
|
],
|
||||||
|
'rejected' => [
|
||||||
|
'label' => 'Requisição rejeitada',
|
||||||
|
'subject' => 'Requisição {reference} rejeitada',
|
||||||
|
'message' => 'A sua requisição {reference} foi rejeitada.',
|
||||||
|
],
|
||||||
|
'information_requested' => [
|
||||||
|
'label' => 'Pedido de esclarecimentos',
|
||||||
|
'subject' => 'Esclarecimentos necessários para {reference}',
|
||||||
|
'message' => 'O aprovador pediu informações adicionais para {reference}.',
|
||||||
|
],
|
||||||
|
'asset_substituted' => [
|
||||||
|
'label' => 'Equipamento substituído',
|
||||||
|
'subject' => 'Equipamento alterado em {reference}',
|
||||||
|
'message' => 'Um equipamento de {reference} foi substituído por outro equivalente.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{subject:string,message:string}> */
|
||||||
|
public function templates(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('portal.notification_templates', now()->addHour(), function (): array {
|
||||||
|
$keys = [];
|
||||||
|
foreach (array_keys($this->definitions()) as $event) {
|
||||||
|
$keys[] = "notification.{$event}.subject";
|
||||||
|
$keys[] = "notification.{$event}.message";
|
||||||
|
}
|
||||||
|
$stored = PortalSetting::query()
|
||||||
|
->whereIn('key', $keys)
|
||||||
|
->get(['key', 'value'])
|
||||||
|
->mapWithKeys(fn (PortalSetting $setting): array => [$setting->key => $setting->value]);
|
||||||
|
|
||||||
|
return collect($this->definitions())->mapWithKeys(function (array $definition, string $event) use ($stored): array {
|
||||||
|
return [$event => [
|
||||||
|
'subject' => (string) ($stored["notification.{$event}.subject"] ?? $definition['subject']),
|
||||||
|
'message' => (string) ($stored["notification.{$event}.message"] ?? $definition['message']),
|
||||||
|
]];
|
||||||
|
})->all();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{subject:string,message:string} */
|
||||||
|
public function render(string $event, Reservation $reservation, ?string $comment = null): array
|
||||||
|
{
|
||||||
|
$template = $this->templates()[$event] ?? [
|
||||||
|
'subject' => 'Atualização da requisição {reference}',
|
||||||
|
'message' => 'O estado da requisição {reference} foi atualizado.',
|
||||||
|
];
|
||||||
|
$replacements = [
|
||||||
|
'{reference}' => $reservation->reference,
|
||||||
|
'{requester}' => $reservation->requester->name,
|
||||||
|
'{starts_at}' => $reservation->starts_at->format('d/m/Y H:i'),
|
||||||
|
'{ends_at}' => $reservation->ends_at->format('d/m/Y H:i'),
|
||||||
|
'{comment}' => $comment ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => strtr($template['subject'], $replacements),
|
||||||
|
'message' => strtr($template['message'], $replacements),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, array{subject:string,message:string}> $templates */
|
||||||
|
public function update(array $templates, User $actor): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($templates, $actor): void {
|
||||||
|
$before = $this->templates();
|
||||||
|
foreach ($templates as $event => $template) {
|
||||||
|
foreach (['subject', 'message'] as $field) {
|
||||||
|
PortalSetting::query()->updateOrCreate(
|
||||||
|
['key' => "notification.{$event}.{$field}"],
|
||||||
|
[
|
||||||
|
'value' => $template[$field],
|
||||||
|
'description' => "Modelo {$field} para {$event}",
|
||||||
|
'updated_by' => $actor->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'event' => 'settings.notification_templates_updated',
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $templates,
|
||||||
|
]);
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
Cache::forget('portal.notification_templates');
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Domain/Administration/Services/ReservationPolicy.php
Normal file
100
app/Domain/Administration/Services/ReservationPolicy.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Administration\Services;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Models\PortalSetting;
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ReservationPolicy
|
||||||
|
{
|
||||||
|
/** @return array<string, array{label:string,description:string,default:int,min:int,max:int,unit:string}> */
|
||||||
|
public function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reservation.min_notice_hours' => [
|
||||||
|
'label' => 'Antecedência mínima',
|
||||||
|
'description' => 'Horas mínimas entre a submissão e o início da utilização.',
|
||||||
|
'default' => 0, 'min' => 0, 'max' => 720, 'unit' => 'horas',
|
||||||
|
],
|
||||||
|
'reservation.max_duration_hours' => [
|
||||||
|
'label' => 'Duração máxima',
|
||||||
|
'description' => 'Duração máxima permitida para uma requisição.',
|
||||||
|
'default' => 168, 'min' => 1, 'max' => 2160, 'unit' => 'horas',
|
||||||
|
],
|
||||||
|
'reservation.turnaround_minutes' => [
|
||||||
|
'label' => 'Margem entre reservas',
|
||||||
|
'description' => 'Tempo bloqueado antes e depois da utilização para preparação e conferência.',
|
||||||
|
'default' => 0, 'min' => 0, 'max' => 1440, 'unit' => 'minutos',
|
||||||
|
],
|
||||||
|
'reservation.approval_deadline_hours' => [
|
||||||
|
'label' => 'Prazo de aprovação',
|
||||||
|
'description' => 'Tempo até uma pré-reserva pendente expirar.',
|
||||||
|
'default' => 48, 'min' => 1, 'max' => 336, 'unit' => 'horas',
|
||||||
|
],
|
||||||
|
'reservation.max_assets' => [
|
||||||
|
'label' => 'Equipamentos por requisição',
|
||||||
|
'description' => 'Número máximo de equipamentos no mesmo pedido.',
|
||||||
|
'default' => 10, 'min' => 1, 'max' => 50, 'unit' => 'equipamentos',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function integer(string $key): int
|
||||||
|
{
|
||||||
|
$definitions = $this->definitions();
|
||||||
|
if (! isset($definitions[$key])) {
|
||||||
|
throw new \InvalidArgumentException("Política desconhecida: {$key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->values()[$key] ?? $definitions[$key]['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, int> */
|
||||||
|
public function values(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('portal.reservation_policy', now()->addHour(), function (): array {
|
||||||
|
/** @var array<string, int> $stored */
|
||||||
|
$stored = PortalSetting::query()
|
||||||
|
->whereIn('key', array_keys($this->definitions()))
|
||||||
|
->pluck('value', 'key')
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return collect($this->definitions())
|
||||||
|
->mapWithKeys(fn (array $definition, string $key): array => [
|
||||||
|
$key => $stored[$key] ?? $definition['default'],
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, int> $values */
|
||||||
|
public function update(array $values, User $actor): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($values, $actor): void {
|
||||||
|
$before = $this->values();
|
||||||
|
foreach ($this->definitions() as $key => $definition) {
|
||||||
|
PortalSetting::query()->updateOrCreate(
|
||||||
|
['key' => $key],
|
||||||
|
[
|
||||||
|
'value' => $values[$key],
|
||||||
|
'description' => $definition['description'],
|
||||||
|
'updated_by' => $actor->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'event' => 'settings.reservation_policy_updated',
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $values,
|
||||||
|
]);
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
Cache::forget('portal.reservation_policy');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,8 @@ class ApprovalService
|
|||||||
'snipe_asset_id' => $newAsset->id,
|
'snipe_asset_id' => $newAsset->id,
|
||||||
'starts_at' => $lockedItem->starts_at,
|
'starts_at' => $lockedItem->starts_at,
|
||||||
'ends_at' => $lockedItem->ends_at,
|
'ends_at' => $lockedItem->ends_at,
|
||||||
|
'buffer_starts_at' => $lockedItem->buffer_starts_at,
|
||||||
|
'buffer_ends_at' => $lockedItem->buffer_ends_at,
|
||||||
'status' => ReservationItemStatus::Held,
|
'status' => ReservationItemStatus::Held,
|
||||||
'notes' => "Substitui {$original->asset_tag}: {$reason}",
|
'notes' => "Substitui {$original->asset_tag}: {$reason}",
|
||||||
]);
|
]);
|
||||||
|
|||||||
135
app/Domain/Identity/Services/UserAdministrationService.php
Normal file
135
app/Domain/Identity/Services/UserAdministrationService.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Identity\Services;
|
||||||
|
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class UserAdministrationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{name:string,email:string,department:?string,snipe_user_id:?int,password:string,active:bool,roles:list<string>} $data
|
||||||
|
*/
|
||||||
|
public function create(User $actor, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($actor, $data): User {
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'department' => $data['department'],
|
||||||
|
'snipe_user_id' => $data['snipe_user_id'],
|
||||||
|
'password' => $data['password'],
|
||||||
|
'active' => $data['active'],
|
||||||
|
]);
|
||||||
|
$user->roles()->sync($this->roleIds($data['roles']));
|
||||||
|
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'event' => 'user.created',
|
||||||
|
'auditable_type' => $user->getMorphClass(),
|
||||||
|
'auditable_id' => $user->id,
|
||||||
|
'after' => $this->snapshot($user),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $user->load('roles');
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list<string>} $data
|
||||||
|
*/
|
||||||
|
public function update(User $user, User $actor, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($user, $actor, $data): User {
|
||||||
|
$locked = User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$before = $this->snapshot($locked);
|
||||||
|
$removesAdministrator = ! in_array(RoleName::Administrator->value, $data['roles'], true);
|
||||||
|
|
||||||
|
if ($locked->id === $actor->id && (! $data['active'] || $removesAdministrator)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'roles' => 'Não pode desativar a própria conta nem remover o seu perfil de administrador.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((! $data['active'] || $removesAdministrator)
|
||||||
|
&& $locked->hasRole(RoleName::Administrator)
|
||||||
|
&& $this->activeAdministratorCount() <= 1) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'roles' => 'O portal tem de manter pelo menos um administrador ativo.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'department' => $data['department'],
|
||||||
|
'snipe_user_id' => $data['snipe_user_id'],
|
||||||
|
'active' => $data['active'],
|
||||||
|
];
|
||||||
|
if (filled($data['password'])) {
|
||||||
|
$attributes['password'] = $data['password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$locked->update($attributes);
|
||||||
|
$locked->roles()->sync($this->roleIds($data['roles']));
|
||||||
|
$locked->load('roles');
|
||||||
|
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'event' => 'user.updated',
|
||||||
|
'auditable_type' => $locked->getMorphClass(),
|
||||||
|
'auditable_id' => $locked->id,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $this->snapshot($locked),
|
||||||
|
'metadata' => ['password_changed' => filled($data['password'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $locked;
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roles
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function roleIds(array $roles): array
|
||||||
|
{
|
||||||
|
$ids = array_values(Role::query()
|
||||||
|
->whereIn('name', $roles)
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (int $id): int => $id)
|
||||||
|
->all());
|
||||||
|
|
||||||
|
if (count($ids) !== count(array_unique($roles))) {
|
||||||
|
throw ValidationException::withMessages(['roles' => 'Um ou mais perfis selecionados não existem.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeAdministratorCount(): int
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('active', true)
|
||||||
|
->whereHas('roles', fn ($query) => $query->where('name', RoleName::Administrator->value))
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function snapshot(User $user): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'department' => $user->department,
|
||||||
|
'snipe_user_id' => $user->snipe_user_id,
|
||||||
|
'active' => $user->active,
|
||||||
|
'roles' => $user->roles()->orderBy('name')->pluck('name')->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class ReservationItem extends Model
|
class ReservationItem extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'status',
|
'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'buffer_starts_at', 'buffer_ends_at', 'status',
|
||||||
'delivered_at', 'returned_at', 'checkout_reference', 'checkin_reference',
|
'delivered_at', 'returned_at', 'checkout_reference', 'checkin_reference',
|
||||||
'condition_out', 'condition_in', 'notes',
|
'condition_out', 'condition_in', 'notes',
|
||||||
];
|
];
|
||||||
@@ -30,6 +30,8 @@ class ReservationItem extends Model
|
|||||||
'status' => ReservationItemStatus::class,
|
'status' => ReservationItemStatus::class,
|
||||||
'starts_at' => 'immutable_datetime',
|
'starts_at' => 'immutable_datetime',
|
||||||
'ends_at' => 'immutable_datetime',
|
'ends_at' => 'immutable_datetime',
|
||||||
|
'buffer_starts_at' => 'immutable_datetime',
|
||||||
|
'buffer_ends_at' => 'immutable_datetime',
|
||||||
'delivered_at' => 'immutable_datetime',
|
'delivered_at' => 'immutable_datetime',
|
||||||
'returned_at' => 'immutable_datetime',
|
'returned_at' => 'immutable_datetime',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,24 +2,30 @@
|
|||||||
|
|
||||||
namespace App\Domain\Reservations\Services;
|
namespace App\Domain\Reservations\Services;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\ReservationPolicy;
|
||||||
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 Carbon\CarbonImmutable;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class AvailabilityService
|
class AvailabilityService
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ReservationPolicy $policy) {}
|
||||||
|
|
||||||
/** @return Builder<SnipeAsset> */
|
/** @return Builder<SnipeAsset> */
|
||||||
public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder
|
public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder
|
||||||
{
|
{
|
||||||
|
[$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt);
|
||||||
|
|
||||||
return SnipeAsset::query()
|
return SnipeAsset::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
->where('requestable', true)
|
->where('requestable', true)
|
||||||
->whereNull('assigned_id')
|
->whereNull('assigned_id')
|
||||||
->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query
|
->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query
|
||||||
->whereIn('status', ReservationItemStatus::blockingValues())
|
->whereIn('status', ReservationItemStatus::blockingValues())
|
||||||
->where('starts_at', '<', $endsAt)
|
->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt])
|
||||||
->where('ends_at', '>', $startsAt))
|
->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt]))
|
||||||
->whereDoesntHave('blocks', fn (Builder $query): Builder => $query
|
->whereDoesntHave('blocks', fn (Builder $query): Builder => $query
|
||||||
->where('starts_at', '<', $endsAt)
|
->where('starts_at', '<', $endsAt)
|
||||||
->where('ends_at', '>', $startsAt));
|
->where('ends_at', '>', $startsAt));
|
||||||
@@ -35,11 +41,12 @@ class AvailabilityService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt);
|
||||||
$hasReservationConflict = $asset->reservationItems()
|
$hasReservationConflict = $asset->reservationItems()
|
||||||
->whereIn('status', ReservationItemStatus::blockingValues())
|
->whereIn('status', ReservationItemStatus::blockingValues())
|
||||||
->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId))
|
->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId))
|
||||||
->where('starts_at', '<', $endsAt)
|
->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt])
|
||||||
->where('ends_at', '>', $startsAt)
|
->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt])
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($hasReservationConflict) {
|
if ($hasReservationConflict) {
|
||||||
@@ -51,4 +58,15 @@ class AvailabilityService
|
|||||||
->where('ends_at', '>', $startsAt)
|
->where('ends_at', '>', $startsAt)
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{DateTimeInterface, DateTimeInterface} */
|
||||||
|
public function bufferedPeriod(DateTimeInterface $startsAt, DateTimeInterface $endsAt): array
|
||||||
|
{
|
||||||
|
$margin = $this->policy->integer('reservation.turnaround_minutes');
|
||||||
|
|
||||||
|
return [
|
||||||
|
CarbonImmutable::instance($startsAt)->subMinutes($margin),
|
||||||
|
CarbonImmutable::instance($endsAt)->addMinutes($margin),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domain\Reservations\Services;
|
namespace App\Domain\Reservations\Services;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\ReservationPolicy;
|
||||||
use App\Domain\Approvals\Models\ApprovalAction;
|
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\Identity\Enums\RoleName;
|
||||||
@@ -20,7 +21,10 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
class ReservationService
|
class ReservationService
|
||||||
{
|
{
|
||||||
public function __construct(private readonly AvailabilityService $availability) {}
|
public function __construct(
|
||||||
|
private readonly AvailabilityService $availability,
|
||||||
|
private readonly ReservationPolicy $policy,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list<int>} $data
|
* @param array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list<int>} $data
|
||||||
@@ -29,8 +33,10 @@ 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']);
|
||||||
|
$this->validatePolicy($startsAt, $endsAt, count($data['asset_ids']));
|
||||||
|
[$bufferStartsAt, $bufferEndsAt] = $this->availability->bufferedPeriod($startsAt, $endsAt);
|
||||||
|
|
||||||
$reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation {
|
$reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt, $bufferStartsAt, $bufferEndsAt): Reservation {
|
||||||
$assets = SnipeAsset::query()
|
$assets = SnipeAsset::query()
|
||||||
->whereKey($data['asset_ids'])
|
->whereKey($data['asset_ids'])
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -58,7 +64,7 @@ class ReservationService
|
|||||||
'starts_at' => $startsAt,
|
'starts_at' => $startsAt,
|
||||||
'ends_at' => $endsAt,
|
'ends_at' => $endsAt,
|
||||||
'status' => ReservationStatus::PendingApproval,
|
'status' => ReservationStatus::PendingApproval,
|
||||||
'approval_deadline' => now()->addHours(48),
|
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($assets as $asset) {
|
foreach ($assets as $asset) {
|
||||||
@@ -66,6 +72,8 @@ class ReservationService
|
|||||||
'snipe_asset_id' => $asset->id,
|
'snipe_asset_id' => $asset->id,
|
||||||
'starts_at' => $startsAt,
|
'starts_at' => $startsAt,
|
||||||
'ends_at' => $endsAt,
|
'ends_at' => $endsAt,
|
||||||
|
'buffer_starts_at' => $bufferStartsAt,
|
||||||
|
'buffer_ends_at' => $bufferEndsAt,
|
||||||
'status' => ReservationItemStatus::Held,
|
'status' => ReservationItemStatus::Held,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -156,7 +164,7 @@ class ReservationService
|
|||||||
|
|
||||||
$locked->update([
|
$locked->update([
|
||||||
'status' => ReservationStatus::PendingApproval,
|
'status' => ReservationStatus::PendingApproval,
|
||||||
'approval_deadline' => now()->addHours(48),
|
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
|
||||||
]);
|
]);
|
||||||
ApprovalAction::create([
|
ApprovalAction::create([
|
||||||
'reservation_id' => $locked->id,
|
'reservation_id' => $locked->id,
|
||||||
@@ -258,4 +266,21 @@ class ReservationService
|
|||||||
|
|
||||||
return $reference;
|
return $reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function validatePolicy(CarbonImmutable $startsAt, CarbonImmutable $endsAt, int $assetCount): void
|
||||||
|
{
|
||||||
|
$minNotice = $this->policy->integer('reservation.min_notice_hours');
|
||||||
|
$maxDuration = $this->policy->integer('reservation.max_duration_hours');
|
||||||
|
$maxAssets = $this->policy->integer('reservation.max_assets');
|
||||||
|
|
||||||
|
if ($startsAt->lessThan(now()->addHours($minNotice))) {
|
||||||
|
throw ValidationException::withMessages(['starts_at' => "A requisição exige pelo menos {$minNotice} horas de antecedência."]);
|
||||||
|
}
|
||||||
|
if ($startsAt->diffInMinutes($endsAt) > $maxDuration * 60) {
|
||||||
|
throw ValidationException::withMessages(['ends_at' => "A duração máxima permitida é de {$maxDuration} horas."]);
|
||||||
|
}
|
||||||
|
if ($assetCount > $maxAssets) {
|
||||||
|
throw ValidationException::withMessages(['asset_ids' => "Selecione no máximo {$maxAssets} equipamentos."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class NotificationTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function edit(NotificationTemplateService $service): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('admin/notification-templates', [
|
||||||
|
'definitions' => $service->definitions(),
|
||||||
|
'templates' => $service->templates(),
|
||||||
|
'variables' => ['{reference}', '{requester}', '{starts_at}', '{ends_at}', '{comment}'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, NotificationTemplateService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$rules = [];
|
||||||
|
foreach (array_keys($service->definitions()) as $event) {
|
||||||
|
$rules["templates.{$event}.subject"] = ['required', 'string', 'max:200'];
|
||||||
|
$rules["templates.{$event}.message"] = ['required', 'string', 'max:2000'];
|
||||||
|
}
|
||||||
|
/** @var array{templates:array<string,array{subject:string,message:string}>} $data */
|
||||||
|
$data = $request->validate($rules);
|
||||||
|
$service->update($data['templates'], $request->user());
|
||||||
|
|
||||||
|
return back()->with('success', 'Modelos de notificação atualizados.');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Controllers/Admin/ReservationPolicyController.php
Normal file
37
app/Http/Controllers/Admin/ReservationPolicyController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\ReservationPolicy;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ReservationPolicyController extends Controller
|
||||||
|
{
|
||||||
|
public function edit(ReservationPolicy $policy): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('admin/reservation-policy', [
|
||||||
|
'definitions' => $policy->definitions(),
|
||||||
|
'values' => $policy->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, ReservationPolicy $policy): RedirectResponse
|
||||||
|
{
|
||||||
|
$rules = [];
|
||||||
|
foreach ($policy->definitions() as $key => $definition) {
|
||||||
|
$rules[str_replace('.', '\\.', $key)] = ['required', 'integer', 'min:'.$definition['min'], 'max:'.$definition['max']];
|
||||||
|
}
|
||||||
|
$request->validate($rules);
|
||||||
|
/** @var array<string, int> $values */
|
||||||
|
$values = collect(array_keys($policy->definitions()))
|
||||||
|
->mapWithKeys(fn (string $key): array => [$key => (int) $request->all()[$key]])
|
||||||
|
->all();
|
||||||
|
$policy->update($values, $request->user());
|
||||||
|
|
||||||
|
return back()->with('success', 'Regras de reserva atualizadas e aplicadas a novos pedidos.');
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Http/Controllers/Admin/UserController.php
Normal file
83
app/Http/Controllers/Admin/UserController.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Domain\Identity\Services\UserAdministrationService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'search' => ['nullable', 'string', 'max:100'],
|
||||||
|
'role' => ['nullable', Rule::enum(RoleName::class)],
|
||||||
|
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('admin/users/index', [
|
||||||
|
'users' => User::query()
|
||||||
|
->with('roles:id,name,description')
|
||||||
|
->when($filters['search'] ?? null, fn ($query, string $search) => $query
|
||||||
|
->where(fn ($query) => $query
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%")
|
||||||
|
->orWhere('department', 'like', "%{$search}%")))
|
||||||
|
->when($filters['role'] ?? null, fn ($query, string $role) => $query
|
||||||
|
->whereHas('roles', fn ($query) => $query->where('name', $role)))
|
||||||
|
->when(($filters['status'] ?? null) === 'active', fn ($query) => $query->where('active', true))
|
||||||
|
->when(($filters['status'] ?? null) === 'inactive', fn ($query) => $query->where('active', false))
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(20)
|
||||||
|
->withQueryString(),
|
||||||
|
'roles' => Role::query()->orderBy('name')->get(['id', 'name', 'description']),
|
||||||
|
'filters' => $filters,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, UserAdministrationService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $this->validatedData($request, null);
|
||||||
|
$user = $service->create($request->user(), $data);
|
||||||
|
|
||||||
|
return back()->with('success', "Utilizador {$user->name} criado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, User $user, UserAdministrationService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $this->validatedData($request, $user);
|
||||||
|
$service->update($user, $request->user(), $data);
|
||||||
|
|
||||||
|
return back()->with('success', "Utilizador {$user->name} atualizado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list<string>}
|
||||||
|
*/
|
||||||
|
private function validatedData(Request $request, ?User $user): array
|
||||||
|
{
|
||||||
|
$roleNames = array_map(fn (RoleName $role): string => $role->value, RoleName::cases());
|
||||||
|
/** @var array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list<string>} $data */
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user?->id)],
|
||||||
|
'department' => ['nullable', 'string', 'max:255'],
|
||||||
|
'snipe_user_id' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'password' => [$user === null ? 'required' : 'nullable', 'string', Password::min(12)],
|
||||||
|
'active' => ['required', 'boolean'],
|
||||||
|
'roles' => ['required', 'array', 'min:1'],
|
||||||
|
'roles.*' => ['required', 'string', 'distinct', Rule::in($roleNames)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Controllers/AuditController.php
Normal file
38
app/Http/Controllers/AuditController.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class AuditController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'event' => ['nullable', 'string', 'max:100'],
|
||||||
|
'actor_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
|
'date_from' => ['nullable', 'date'],
|
||||||
|
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('audit/index', [
|
||||||
|
'logs' => AuditLog::query()
|
||||||
|
->with('actor:id,name,email')
|
||||||
|
->when($filters['event'] ?? null, fn ($query, string $event) => $query->where('event', 'like', "%{$event}%"))
|
||||||
|
->when($filters['actor_id'] ?? null, fn ($query, int $actorId) => $query->where('actor_id', $actorId))
|
||||||
|
->when($filters['date_from'] ?? null, fn ($query, string $date) => $query->where('created_at', '>=', $date.' 00:00:00'))
|
||||||
|
->when($filters['date_to'] ?? null, fn ($query, string $date) => $query->where('created_at', '<', CarbonImmutable::parse($date)->addDay()))
|
||||||
|
->latest('created_at')
|
||||||
|
->paginate(50)
|
||||||
|
->withQueryString(),
|
||||||
|
'actors' => User::query()->whereHas('roles')->orderBy('name')->get(['id', 'name']),
|
||||||
|
'events' => AuditLog::query()->distinct()->orderBy('event')->pluck('event'),
|
||||||
|
'filters' => $filters,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Controllers/GlobalCalendarController.php
Normal file
31
app/Http/Controllers/GlobalCalendarController.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class GlobalCalendarController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$filters = $request->validate(['month' => ['nullable', 'date_format:Y-m']]);
|
||||||
|
$month = isset($filters['month'])
|
||||||
|
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
|
||||||
|
: now()->startOfMonth();
|
||||||
|
|
||||||
|
return Inertia::render('reservations/calendar', [
|
||||||
|
'scope' => 'global',
|
||||||
|
'month' => $month->format('Y-m'),
|
||||||
|
'reservations' => Reservation::query()
|
||||||
|
->with(['requester:id,name,department', 'items.asset:id,name,asset_tag'])
|
||||||
|
->where('starts_at', '<', $month->addMonth())
|
||||||
|
->where('ends_at', '>', $month)
|
||||||
|
->orderBy('starts_at')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/Http/Controllers/ReportController.php
Normal file
109
app/Http/Controllers/ReportController.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
[$filters, $from, $to] = $this->period($request);
|
||||||
|
$reservations = $this->query($from, $to)->get();
|
||||||
|
$items = $reservations->flatMap->items->where('status', '!=', ReservationItemStatus::Cancelled);
|
||||||
|
|
||||||
|
return Inertia::render('reports/index', [
|
||||||
|
'filters' => $filters,
|
||||||
|
'summary' => [
|
||||||
|
'total' => $reservations->count(),
|
||||||
|
'completed' => $reservations->where('status', ReservationStatus::Completed)->count(),
|
||||||
|
'incidents' => AuditLog::query()->where('event', 'reservation.incident_reported')->whereBetween('created_at', [$from, $to])->count(),
|
||||||
|
'overdue' => AuditLog::query()->where('event', 'reservation.overdue')->whereBetween('created_at', [$from, $to])->count(),
|
||||||
|
'utilizationHours' => round($items->sum(function ($item): float {
|
||||||
|
$end = $item->returned_at ?? ($item->ends_at->isPast() ? $item->ends_at : now());
|
||||||
|
|
||||||
|
return $item->delivered_at === null ? 0 : max(0, $item->delivered_at->diffInMinutes($end) / 60);
|
||||||
|
}), 1),
|
||||||
|
],
|
||||||
|
'byStatus' => $reservations->countBy(fn ($reservation): string => $reservation->status->value)->sortDesc(),
|
||||||
|
'byCategory' => $items->countBy(fn ($item): string => $item->asset->category_name ?? 'Sem categoria')->sortDesc()->take(10),
|
||||||
|
'byDepartment' => $reservations->countBy(fn ($reservation): string => $reservation->requester->department ?? 'Sem departamento')->sortDesc()->take(10),
|
||||||
|
'topAssets' => $items->countBy(fn ($item): string => "{$item->asset->asset_tag} — {$item->asset->name}")->sortDesc()->take(10),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(Request $request): StreamedResponse
|
||||||
|
{
|
||||||
|
[$filters, $from, $to] = $this->period($request);
|
||||||
|
$reservations = $this->query($from, $to)->lazy(200);
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($reservations): void {
|
||||||
|
$output = fopen('php://output', 'w');
|
||||||
|
if ($output === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fwrite($output, "\xEF\xBB\xBF");
|
||||||
|
fputcsv($output, ['Referência', 'Requisitante', 'Departamento', 'Estado', 'Início', 'Fim', 'Equipamentos'], ';', '"', '');
|
||||||
|
foreach ($reservations as $reservation) {
|
||||||
|
fputcsv($output, [
|
||||||
|
$this->csvCell($reservation->reference),
|
||||||
|
$this->csvCell($reservation->requester->name),
|
||||||
|
$this->csvCell($reservation->requester->department),
|
||||||
|
$this->csvCell($reservation->status->value),
|
||||||
|
$reservation->starts_at->toIso8601String(),
|
||||||
|
$reservation->ends_at->toIso8601String(),
|
||||||
|
$this->csvCell($reservation->items->pluck('asset.asset_tag')->join(', ')),
|
||||||
|
], ';', '"', '');
|
||||||
|
}
|
||||||
|
fclose($output);
|
||||||
|
}, "reservas-{$filters['date_from']}-{$filters['date_to']}.csv", [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{array{date_from:string,date_to:string},CarbonImmutable,CarbonImmutable} */
|
||||||
|
private function period(Request $request): array
|
||||||
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'date_from' => ['nullable', 'date'],
|
||||||
|
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
|
||||||
|
]);
|
||||||
|
$from = isset($filters['date_from']) ? CarbonImmutable::parse($filters['date_from'])->startOfDay() : now()->subDays(29)->startOfDay();
|
||||||
|
$to = isset($filters['date_to']) ? CarbonImmutable::parse($filters['date_to'])->endOfDay() : now()->endOfDay();
|
||||||
|
if ($from->diffInDays($to) > 366) {
|
||||||
|
throw ValidationException::withMessages(['date_to' => 'O relatório está limitado a um período máximo de 366 dias.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[
|
||||||
|
'date_from' => $from->toDateString(),
|
||||||
|
'date_to' => $to->toDateString(),
|
||||||
|
], $from, $to];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<Reservation> */
|
||||||
|
private function query(CarbonImmutable $from, CarbonImmutable $to): Builder
|
||||||
|
{
|
||||||
|
return Reservation::query()
|
||||||
|
->with(['requester:id,name,department', 'items.asset:id,name,asset_tag,category_name'])
|
||||||
|
->where('starts_at', '<=', $to)
|
||||||
|
->where('ends_at', '>=', $from)
|
||||||
|
->orderBy('starts_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function csvCell(?string $value): string
|
||||||
|
{
|
||||||
|
$value ??= '';
|
||||||
|
|
||||||
|
return preg_match('/^\s*[=+\-@]/', $value) === 1 ? "'{$value}" : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\ReservationPolicy;
|
||||||
use App\Domain\Inventory\Models\SnipeAsset;
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
use App\Domain\Reservations\Models\Reservation;
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
use App\Domain\Reservations\Services\AvailabilityService;
|
use App\Domain\Reservations\Services\AvailabilityService;
|
||||||
@@ -46,7 +47,7 @@ class ReservationController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(Request $request, AvailabilityService $availability): Response
|
public function create(Request $request, AvailabilityService $availability, ReservationPolicy $policy): Response
|
||||||
{
|
{
|
||||||
$period = $request->validate([
|
$period = $request->validate([
|
||||||
'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'],
|
'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'],
|
||||||
@@ -73,6 +74,12 @@ class ReservationController extends Controller
|
|||||||
'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0,
|
'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0,
|
||||||
'period' => $period,
|
'period' => $period,
|
||||||
'periodSelected' => $periodSelected,
|
'periodSelected' => $periodSelected,
|
||||||
|
'policy' => [
|
||||||
|
'minNoticeHours' => $policy->integer('reservation.min_notice_hours'),
|
||||||
|
'maxDurationHours' => $policy->integer('reservation.max_duration_hours'),
|
||||||
|
'turnaroundMinutes' => $policy->integer('reservation.turnaround_minutes'),
|
||||||
|
'maxAssets' => $policy->integer('reservation.max_assets'),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ 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,
|
||||||
'operateInventory' => fn (): bool => $request->user()?->can('operate-inventory') ?? false,
|
'operateInventory' => fn (): bool => $request->user()?->can('operate-inventory') ?? false,
|
||||||
|
'viewGlobalCalendar' => fn (): bool => $request->user()?->can('view-global-calendar') ?? false,
|
||||||
|
'viewReports' => fn (): bool => $request->user()?->can('view-reports') ?? false,
|
||||||
|
'viewAudit' => fn (): bool => $request->user()?->can('view-audit') ?? false,
|
||||||
],
|
],
|
||||||
'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0,
|
'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',
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\ReservationPolicy;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class StoreReservationRequest extends FormRequest
|
class StoreReservationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -14,14 +17,39 @@ class StoreReservationRequest extends FormRequest
|
|||||||
/** @return array<string, list<string>> */
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$maxAssets = app(ReservationPolicy::class)->integer('reservation.max_assets');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
|
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
|
||||||
'usage_location' => ['required', 'string', 'max:255'],
|
'usage_location' => ['required', 'string', 'max:255'],
|
||||||
'starts_at' => ['required', 'date', 'after:now'],
|
'starts_at' => ['required', 'date', 'after:now'],
|
||||||
'ends_at' => ['required', 'date', 'after:starts_at'],
|
'ends_at' => ['required', 'date', 'after:starts_at'],
|
||||||
'asset_ids' => ['required', 'array', 'min:1', 'max:10'],
|
'asset_ids' => ['required', 'array', 'min:1', "max:{$maxAssets}"],
|
||||||
'asset_ids.*' => ['required', 'integer', 'distinct', 'exists:snipe_assets,id'],
|
'asset_ids.*' => ['required', 'integer', 'distinct', 'exists:snipe_assets,id'],
|
||||||
'terms_accepted' => ['accepted'],
|
'terms_accepted' => ['accepted'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<callable(Validator): void> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [function (Validator $validator): void {
|
||||||
|
if ($validator->errors()->hasAny(['starts_at', 'ends_at'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = app(ReservationPolicy::class);
|
||||||
|
$startsAt = CarbonImmutable::parse((string) $this->input('starts_at'));
|
||||||
|
$endsAt = CarbonImmutable::parse((string) $this->input('ends_at'));
|
||||||
|
$minNotice = $policy->integer('reservation.min_notice_hours');
|
||||||
|
$maxDuration = $policy->integer('reservation.max_duration_hours');
|
||||||
|
|
||||||
|
if ($startsAt->lessThan(now()->addHours($minNotice))) {
|
||||||
|
$validator->errors()->add('starts_at', "A requisição exige pelo menos {$minNotice} horas de antecedência.");
|
||||||
|
}
|
||||||
|
if ($startsAt->diffInMinutes($endsAt) > $maxDuration * 60) {
|
||||||
|
$validator->errors()->add('ends_at', "A duração máxima permitida é de {$maxDuration} horas.");
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
|||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
*/
|
*/
|
||||||
#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])]
|
#[Fillable(['uuid', 'name', 'email', 'email_verified_at', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable implements PasskeyUser
|
class User extends Authenticatable implements PasskeyUser
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||||
use App\Domain\Reservations\Models\Reservation;
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -28,10 +29,11 @@ class ReservationDecisionNotification extends Notification implements ShouldQueu
|
|||||||
|
|
||||||
public function toMail(object $notifiable): MailMessage
|
public function toMail(object $notifiable): MailMessage
|
||||||
{
|
{
|
||||||
|
$template = $this->template();
|
||||||
$message = (new MailMessage)
|
$message = (new MailMessage)
|
||||||
->subject($this->subject())
|
->subject($template['subject'])
|
||||||
->greeting("Atualização da requisição {$this->reservation->reference}")
|
->greeting("Atualização da requisição {$this->reservation->reference}")
|
||||||
->line($this->message());
|
->line($template['message']);
|
||||||
|
|
||||||
if (filled($this->comment)) {
|
if (filled($this->comment)) {
|
||||||
$message->line("Comentário: {$this->comment}");
|
$message->line("Comentário: {$this->comment}");
|
||||||
@@ -43,35 +45,21 @@ class ReservationDecisionNotification extends Notification implements ShouldQueu
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(object $notifiable): array
|
public function toArray(object $notifiable): array
|
||||||
{
|
{
|
||||||
|
$template = $this->template();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'reservation_uuid' => $this->reservation->uuid,
|
'reservation_uuid' => $this->reservation->uuid,
|
||||||
'reference' => $this->reservation->reference,
|
'reference' => $this->reservation->reference,
|
||||||
'event' => $this->event,
|
'event' => $this->event,
|
||||||
'message' => $this->message(),
|
'message' => $template['message'],
|
||||||
'comment' => $this->comment,
|
'comment' => $this->comment,
|
||||||
'url' => '/requisicoes',
|
'url' => '/requisicoes',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function subject(): string
|
/** @return array{subject:string,message:string} */
|
||||||
|
private function template(): array
|
||||||
{
|
{
|
||||||
return match ($this->event) {
|
return app(NotificationTemplateService::class)->render($this->event, $this->reservation, $this->comment);
|
||||||
'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.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||||
use App\Domain\Reservations\Models\Reservation;
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -27,28 +28,27 @@ class ReservationSubmittedNotification extends Notification implements ShouldQue
|
|||||||
|
|
||||||
public function toMail(object $notifiable): MailMessage
|
public function toMail(object $notifiable): MailMessage
|
||||||
{
|
{
|
||||||
|
$template = app(NotificationTemplateService::class)->render($this->event, $this->reservation);
|
||||||
|
|
||||||
return (new MailMessage)
|
return (new MailMessage)
|
||||||
->subject($this->event === 'information_provided'
|
->subject($template['subject'])
|
||||||
? "Resposta recebida para {$this->reservation->reference}"
|
|
||||||
: "Nova requisição {$this->reservation->reference}")
|
|
||||||
->greeting($this->event === 'information_provided'
|
->greeting($this->event === 'information_provided'
|
||||||
? 'O requisitante respondeu ao pedido de esclarecimentos'
|
? 'O requisitante respondeu ao pedido de esclarecimentos'
|
||||||
: 'Nova requisição para aprovação')
|
: 'Nova requisição para aprovação')
|
||||||
->line("{$this->reservation->requester->name} submeteu a requisição {$this->reservation->reference} para decisão.")
|
->line($template['message'])
|
||||||
->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'));
|
->action('Analisar requisição', url('/aprovacoes'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(object $notifiable): array
|
public function toArray(object $notifiable): array
|
||||||
{
|
{
|
||||||
|
$template = app(NotificationTemplateService::class)->render($this->event, $this->reservation);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'reservation_uuid' => $this->reservation->uuid,
|
'reservation_uuid' => $this->reservation->uuid,
|
||||||
'reference' => $this->reservation->reference,
|
'reference' => $this->reservation->reference,
|
||||||
'event' => $this->event,
|
'event' => $this->event,
|
||||||
'message' => $this->event === 'information_provided'
|
'message' => $template['message'],
|
||||||
? "Foram prestados esclarecimentos para {$this->reservation->reference}."
|
|
||||||
: "Nova requisição {$this->reservation->reference} aguarda decisão.",
|
|
||||||
'url' => '/aprovacoes',
|
'url' => '/aprovacoes',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
fn (User $user): bool => $user->active
|
fn (User $user): bool => $user->active
|
||||||
&& ($user->hasRole(RoleName::InventoryOperator) || $user->hasRole(RoleName::Administrator)),
|
&& ($user->hasRole(RoleName::InventoryOperator) || $user->hasRole(RoleName::Administrator)),
|
||||||
);
|
);
|
||||||
|
Gate::define(
|
||||||
|
'view-global-calendar',
|
||||||
|
fn (User $user): bool => $user->active && (
|
||||||
|
$user->hasRole(RoleName::Approver)
|
||||||
|
|| $user->hasRole(RoleName::InventoryOperator)
|
||||||
|
|| $user->hasRole(RoleName::Administrator)
|
||||||
|
|| $user->hasRole(RoleName::Auditor)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Gate::define(
|
||||||
|
'view-reports',
|
||||||
|
fn (User $user): bool => $user->active
|
||||||
|
&& ($user->hasRole(RoleName::Administrator) || $user->hasRole(RoleName::Auditor)),
|
||||||
|
);
|
||||||
|
Gate::define(
|
||||||
|
'view-audit',
|
||||||
|
fn (User $user): bool => $user->active
|
||||||
|
&& ($user->hasRole(RoleName::Administrator) || $user->hasRole(RoleName::Auditor)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('portal_settings', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->json('value');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestampsTz();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('reservation_items', function (Blueprint $table) {
|
||||||
|
$table->timestampTz('buffer_starts_at')->nullable()->index()->after('ends_at');
|
||||||
|
$table->timestampTz('buffer_ends_at')->nullable()->index()->after('buffer_starts_at');
|
||||||
|
});
|
||||||
|
DB::table('reservation_items')->update([
|
||||||
|
'buffer_starts_at' => DB::raw('starts_at'),
|
||||||
|
'buffer_ends_at' => DB::raw('ends_at'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (DB::getDriverName() === 'pgsql') {
|
||||||
|
DB::statement('ALTER TABLE reservation_items DROP CONSTRAINT reservation_items_no_blocking_overlap');
|
||||||
|
DB::statement('ALTER TABLE reservation_items ALTER COLUMN buffer_starts_at SET NOT NULL');
|
||||||
|
DB::statement('ALTER TABLE reservation_items ALTER COLUMN buffer_ends_at SET NOT NULL');
|
||||||
|
DB::statement(<<<'SQL'
|
||||||
|
ALTER TABLE reservation_items
|
||||||
|
ADD CONSTRAINT reservation_items_no_blocking_overlap
|
||||||
|
EXCLUDE USING gist (
|
||||||
|
snipe_asset_id WITH =,
|
||||||
|
tstzrange(buffer_starts_at, buffer_ends_at, '[)') WITH &&
|
||||||
|
)
|
||||||
|
WHERE (status IN ('held', 'reserved', 'ready', 'delivered'))
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() === 'pgsql') {
|
||||||
|
DB::statement('ALTER TABLE reservation_items DROP CONSTRAINT reservation_items_no_blocking_overlap');
|
||||||
|
DB::statement(<<<'SQL'
|
||||||
|
ALTER TABLE reservation_items
|
||||||
|
ADD CONSTRAINT reservation_items_no_blocking_overlap
|
||||||
|
EXCLUDE USING gist (
|
||||||
|
snipe_asset_id WITH =,
|
||||||
|
tstzrange(starts_at, ends_at, '[)') WITH &&
|
||||||
|
)
|
||||||
|
WHERE (status IN ('held', 'reserved', 'ready', 'delivered'))
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('reservation_items', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['buffer_starts_at', 'buffer_ends_at']);
|
||||||
|
});
|
||||||
|
Schema::dropIfExists('portal_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -30,6 +30,7 @@ Nginx ──► Laravel + Inertia/React
|
|||||||
## Módulos
|
## Módulos
|
||||||
|
|
||||||
- `app/Domain/Identity`: perfis, permissões e identidades externas.
|
- `app/Domain/Identity`: perfis, permissões e identidades externas.
|
||||||
|
- `app/Domain/Administration`: políticas persistentes, modelos de notificação e configuração funcional.
|
||||||
- `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, histórico e concorrência de aprovação.
|
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação.
|
||||||
@@ -56,6 +57,12 @@ Pré-reservas ficam bloqueantes em `held` enquanto aguardam decisão. `Reservati
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
`ReservationPolicy` fornece defaults versionados e sobreposições guardadas em `portal_settings`. Antecedência, duração, limite de itens e prazo são validados no pedido e no serviço de domínio. A margem operacional é materializada em `reservation_items.buffer_starts_at`/`buffer_ends_at`; a restrição GiST usa estes campos, mantendo a proteção contra corridas. Uma alteração de política só afeta novas reservas, preservando o contrato temporal das existentes.
|
||||||
|
|
||||||
|
Os relatórios usam consultas de leitura com período máximo de 366 dias. A exportação CSV é transmitida em blocos e neutraliza conteúdos interpretáveis como fórmulas. Auditoria, relatórios e calendário global têm Gates próprios; o perfil `auditor` permanece exclusivamente de leitura.
|
||||||
|
|
||||||
|
`NotificationTemplateService` mantém defaults no código e sobreposições em `portal_settings`. Apenas variáveis conhecidas (`{reference}`, `{requester}`, `{starts_at}`, `{ends_at}`, `{comment}`) são substituídas; a alteração de modelos é auditada e nunca inclui credenciais ou configuração SMTP.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -78,3 +78,17 @@ Notificações funcionais são colocadas na fila apenas depois da confirmação
|
|||||||
Checkout e check-in usam um token operacional distinto do token de leitura e nunca escrevem diretamente na base de dados do Snipe-IT. Cada equipamento é movimentado remotamente antes de o respetivo `reservation_item` e `handover_record` serem confirmados localmente. Escritas não têm retry automático: uma falha de ligação ou HTTP 5xx pode ser posterior à execução remota e a repetição cega não é segura. Numa operação com vários itens, sucessos confirmados são preservados e uma falha posterior coloca a requisição em `incident`, com IDs concluídos, item falhado e erro em auditoria. O operador reconcilia o ativo no Snipe-IT e repete apenas o movimento ainda pendente.
|
Checkout e check-in usam um token operacional distinto do token de leitura e nunca escrevem diretamente na base de dados do Snipe-IT. Cada equipamento é movimentado remotamente antes de o respetivo `reservation_item` e `handover_record` serem confirmados localmente. Escritas não têm retry automático: uma falha de ligação ou HTTP 5xx pode ser posterior à execução remota e a repetição cega não é segura. Numa operação com vários itens, sucessos confirmados são preservados e uma falha posterior coloca a requisição em `incident`, com IDs concluídos, item falhado e erro em auditoria. O operador reconcilia o ativo no Snipe-IT e repete apenas o movimento ainda pendente.
|
||||||
|
|
||||||
O checkout é dirigido a `checkout_to_type=user`; o ID remoto confirmado pelo operador é guardado em `users.snipe_user_id`. O check-in usa o estado e a localização habitual provenientes da última sincronização. Esta opção segue os contratos oficiais de [checkout](https://snipe-it.readme.io/reference/hardware-checkout), [check-in](https://snipe-it.readme.io/reference/hardware-checkin) e o comportamento documentado de [erros em HTTP 200](https://snipe-it.readme.io/reference/api-overview).
|
O checkout é dirigido a `checkout_to_type=user`; o ID remoto confirmado pelo operador é guardado em `users.snipe_user_id`. O check-in usa o estado e a localização habitual provenientes da última sincronização. Esta opção segue os contratos oficiais de [checkout](https://snipe-it.readme.io/reference/hardware-checkout), [check-in](https://snipe-it.readme.io/reference/hardware-checkin) e o comportamento documentado de [erros em HTTP 200](https://snipe-it.readme.io/reference/api-overview).
|
||||||
|
|
||||||
|
## ADR-012 — Políticas persistentes e buffers temporais imutáveis
|
||||||
|
|
||||||
|
**Data:** 2026-08-04
|
||||||
|
**Estado:** aceite
|
||||||
|
|
||||||
|
Guardar parâmetros funcionais em `portal_settings`, mantendo defaults seguros no código. Antecedência, duração, prazo e limite de itens aplicam-se a novos pedidos. A margem é materializada em cada item através de `buffer_starts_at`/`buffer_ends_at` e protegida pela restrição de exclusão PostgreSQL. Alterar a política não recalcula reservas existentes: o período bloqueante aceite no momento da submissão permanece estável e auditável.
|
||||||
|
|
||||||
|
## ADR-013 — Supervisão de leitura e exportações seguras
|
||||||
|
|
||||||
|
**Data:** 2026-08-04
|
||||||
|
**Estado:** aceite
|
||||||
|
|
||||||
|
Separar Gates para calendário global, relatórios e auditoria. Administradores têm acesso integral; auditores consultam relatórios/auditoria sem mutações; aprovadores e operadores consultam o calendário global. Relatórios são limitados a 366 dias, exportados por streaming e neutralizam possíveis fórmulas em CSV. Modelos de notificação são conteúdo funcional persistente, mas credenciais SMTP e tokens continuam exclusivamente no ambiente.
|
||||||
|
|||||||
@@ -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 a 5 concluídas; próxima entrega: Fase 6
|
**Fase:** Fases 1 a 6 concluídas; próxima entrega: Fase 7
|
||||||
**Último agente:** Codex
|
**Último agente:** Codex
|
||||||
|
|
||||||
## Resultado disponível
|
## Resultado disponível
|
||||||
@@ -40,6 +40,15 @@
|
|||||||
- Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida.
|
- Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida.
|
||||||
- Tratamento explícito de falhas parciais: movimentos já aceites são preservados, a requisição passa a `incident` e a falha fica em `audit_logs` para reconciliação.
|
- Tratamento explícito de falhas parciais: movimentos já aceites são preservados, a requisição passa a `incident` e a falha fica em `audit_logs` para reconciliação.
|
||||||
- Registo de falta, atraso e incidente; um incidente pode criar um bloqueio temporal do equipamento que entra imediatamente no motor de disponibilidade.
|
- Registo de falta, atraso e incidente; um incidente pode criar um bloqueio temporal do equipamento que entra imediatamente no motor de disponibilidade.
|
||||||
|
- Gestão administrativa de utilizadores com pesquisa, perfis múltiplos, estado ativo, departamento, ID Snipe-IT e reposição de palavra-passe.
|
||||||
|
- Proteção contra desativação da própria conta administrativa e contra remoção do último administrador ativo.
|
||||||
|
- Políticas de reserva editáveis para antecedência, duração, margem operacional, prazo de aprovação e limite de equipamentos.
|
||||||
|
- Margens temporais persistidas por item e garantidas pela restrição de exclusão PostgreSQL, sem reescrever reservas existentes quando a política muda.
|
||||||
|
- Calendário institucional global para aprovadores, operadores, administradores e auditores.
|
||||||
|
- Relatórios por período com totais, horas de utilização, incidentes, atrasos, estados, categorias, departamentos e equipamentos mais requisitados.
|
||||||
|
- Exportação CSV protegida contra formula injection e limitada a períodos de 366 dias.
|
||||||
|
- Consulta paginada e filtrável de `audit_logs`, apenas para administradores e auditores.
|
||||||
|
- Modelos editáveis de assunto/mensagem para os seis eventos de notificação, com variáveis validadas e auditoria das alterações.
|
||||||
|
|
||||||
## Limites conhecidos
|
## Limites conhecidos
|
||||||
|
|
||||||
@@ -49,36 +58,38 @@
|
|||||||
- 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.
|
||||||
- O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada.
|
- O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada.
|
||||||
- Uma falha depois de o Snipe-IT confirmar um movimento e antes do commit local é uma transação distribuída ambígua; não existe retry automático de escritas. O operador deve reconciliar pelos registos Snipe-IT e `audit_logs` conforme o runbook.
|
- Uma falha depois de o Snipe-IT confirmar um movimento e antes do commit local é uma transação distribuída ambígua; não existe retry automático de escritas. O operador deve reconciliar pelos registos Snipe-IT e `audit_logs` conforme o runbook.
|
||||||
|
- As exportações administrativas estão disponíveis em CSV; XLSX poderá ser acrescentado se o processo institucional o exigir.
|
||||||
|
|
||||||
## Próxima tarefa recomendada
|
## Próxima tarefa recomendada
|
||||||
|
|
||||||
Implementar a **Fase 6 — administração e relatórios**:
|
Implementar a **Fase 7 — hardening e entrada em produção**:
|
||||||
|
|
||||||
1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT;
|
1. implementar Microsoft Entra ID/OIDC e associação segura a contas locais;
|
||||||
2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias;
|
2. validar a integração contra o Snipe-IT institucional e configurar tokens reais de menor privilégio;
|
||||||
3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados;
|
3. configurar SMTP institucional, domínio, HTTPS e gestão externa de segredos;
|
||||||
4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado;
|
4. adicionar health checks, monitorização, alertas, backups e teste de restauro;
|
||||||
5. criar consulta de auditoria apenas de leitura para o perfil `auditor`;
|
5. executar testes de segurança, concorrência, desempenho e end-to-end com equipamentos reais;
|
||||||
6. adicionar métricas operacionais e gestão de modelos de email;
|
6. preparar formação, dados iniciais e piloto com um grupo reduzido;
|
||||||
7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação.
|
7. corrigir resultados do piloto e preparar a disponibilização geral.
|
||||||
|
|
||||||
## Testes da última sessão
|
## Testes da última sessão
|
||||||
|
|
||||||
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: 117 ficheiros sem desvios;
|
- Laravel Pint: 132 ficheiros sem desvios;
|
||||||
- PHPStan/Larastan nível 7: 89 ficheiros, zero erros;
|
- PHPStan/Larastan nível 7: 100 ficheiros, zero erros;
|
||||||
- Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
- Pest: 92 testes passaram, 522 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, 2309 módulos cliente e 98 módulos SSR transformados;
|
- Vite: builds cliente e SSR concluídos, 2314 módulos cliente e 103 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;
|
- PostgreSQL 17: migration `notifications` aplicada com sucesso;
|
||||||
- PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada;
|
- PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada;
|
||||||
|
- PostgreSQL 17: migration de `portal_settings` e buffers temporais aplicada; restrição GiST recriada sobre o intervalo com margem;
|
||||||
- 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;
|
||||||
|
|||||||
@@ -128,6 +128,19 @@ Utilizadores com perfil `approver` ou `administrator` acedem a `http://localhost
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Administração e relatórios
|
||||||
|
|
||||||
|
Administradores usam:
|
||||||
|
|
||||||
|
- `http://localhost:8000/administracao/utilizadores` para contas, perfis e IDs Snipe-IT;
|
||||||
|
- `http://localhost:8000/administracao/regras` para políticas aplicadas a novos pedidos;
|
||||||
|
- `http://localhost:8000/administracao/notificacoes/modelos` para assuntos e mensagens;
|
||||||
|
- `http://localhost:8000/administracao/snipe-it` para diagnóstico da integração.
|
||||||
|
|
||||||
|
As alterações são registadas em `audit_logs`. A margem de uma reserva já submetida não muda quando a política é alterada; confirmar `buffer_starts_at` e `buffer_ends_at` do item ao investigar um conflito.
|
||||||
|
|
||||||
|
Administradores e auditores consultam `http://localhost:8000/relatorios` e `http://localhost:8000/auditoria`. O CSV usa `;`, UTF-8 com BOM e neutralização de fórmulas para abertura segura em aplicações de folha de cálculo. Cada pedido está limitado a 366 dias; para análises plurianuais, exportar períodos separados. Aprovadores, operadores, administradores e auditores consultam o planeamento consolidado em `http://localhost:8000/calendario-global`.
|
||||||
|
|
||||||
## Produção
|
## Produção
|
||||||
|
|
||||||
Antes de disponibilizar:
|
Antes de disponibilizar:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
BarChart3,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
PlusCircle,
|
PlusCircle,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ScrollText,
|
||||||
|
UsersRound,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Mails,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import AppLogo from '@/components/app-logo';
|
import AppLogo from '@/components/app-logo';
|
||||||
import { NavFooter } from '@/components/nav-footer';
|
import { NavFooter } from '@/components/nav-footer';
|
||||||
@@ -82,16 +87,53 @@ export function AppSidebar() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: roleNavItems;
|
: roleNavItems;
|
||||||
const visibleNavItems = can.administer
|
const calendarNavItems = can.viewGlobalCalendar
|
||||||
? [
|
? [
|
||||||
...operationalNavItems,
|
...operationalNavItems,
|
||||||
|
{
|
||||||
|
title: 'Calendário global',
|
||||||
|
href: '/calendario-global',
|
||||||
|
icon: CalendarRange,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: operationalNavItems;
|
||||||
|
const reportNavItems = can.viewReports
|
||||||
|
? [
|
||||||
|
...calendarNavItems,
|
||||||
|
{ title: 'Relatórios', href: '/relatorios', icon: BarChart3 },
|
||||||
|
]
|
||||||
|
: calendarNavItems;
|
||||||
|
const oversightNavItems = can.viewAudit
|
||||||
|
? [
|
||||||
|
...reportNavItems,
|
||||||
|
{ title: 'Auditoria', href: '/auditoria', icon: ScrollText },
|
||||||
|
]
|
||||||
|
: reportNavItems;
|
||||||
|
const visibleNavItems = can.administer
|
||||||
|
? [
|
||||||
|
...oversightNavItems,
|
||||||
|
{
|
||||||
|
title: 'Utilizadores',
|
||||||
|
href: '/administracao/utilizadores',
|
||||||
|
icon: UsersRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Regras de reserva',
|
||||||
|
href: '/administracao/regras',
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Modelos de email',
|
||||||
|
href: '/administracao/notificacoes/modelos',
|
||||||
|
icon: Mails,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Integração Snipe-IT',
|
title: 'Integração Snipe-IT',
|
||||||
href: '/administracao/snipe-it',
|
href: '/administracao/snipe-it',
|
||||||
icon: ServerCog,
|
icon: ServerCog,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: operationalNavItems;
|
: oversightNavItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar collapsible="icon" variant="inset">
|
||||||
|
|||||||
144
resources/js/pages/admin/notification-templates.tsx
Normal file
144
resources/js/pages/admin/notification-templates.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Head, useForm } from '@inertiajs/react';
|
||||||
|
import { Mails, Save } from 'lucide-react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeading } from '@/components/page-heading';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
type Template = { subject: string; message: string };
|
||||||
|
type Definition = Template & { label: string };
|
||||||
|
|
||||||
|
export default function NotificationTemplates({
|
||||||
|
definitions,
|
||||||
|
templates,
|
||||||
|
variables,
|
||||||
|
}: {
|
||||||
|
definitions: Record<string, Definition>;
|
||||||
|
templates: Record<string, Template>;
|
||||||
|
variables: string[];
|
||||||
|
}) {
|
||||||
|
const form = useForm({ templates });
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
form.put('/administracao/notificacoes/modelos', {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(eventName: string, field: keyof Template, value: string) {
|
||||||
|
form.setData('templates', {
|
||||||
|
...form.data.templates,
|
||||||
|
[eventName]: { ...form.data.templates[eventName], [field]: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Modelos de email" />
|
||||||
|
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Administração"
|
||||||
|
title="Modelos de notificação"
|
||||||
|
description="Personalize o assunto e a mensagem usados no email e na caixa interna, mantendo as variáveis funcionais."
|
||||||
|
/>
|
||||||
|
<p className="rounded-xl border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||||
|
Variáveis disponíveis:{' '}
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<code
|
||||||
|
key={variable}
|
||||||
|
className="mx-1 rounded bg-background px-1.5 py-0.5"
|
||||||
|
>
|
||||||
|
{variable}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{Object.entries(definitions).map(
|
||||||
|
([eventName, definition]) => (
|
||||||
|
<Card
|
||||||
|
key={eventName}
|
||||||
|
className="gap-4 shadow-none"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Mails className="size-4 text-primary" />
|
||||||
|
{definition.label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
Assunto
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
form.data.templates[
|
||||||
|
eventName
|
||||||
|
].subject
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
update(
|
||||||
|
eventName,
|
||||||
|
'subject',
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
form.errors[
|
||||||
|
`templates.${eventName}.subject`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label className="block space-y-1 text-sm font-medium">
|
||||||
|
Mensagem
|
||||||
|
<textarea
|
||||||
|
className="min-h-24 w-full rounded-md border bg-transparent px-3 py-2 text-sm"
|
||||||
|
value={
|
||||||
|
form.data.templates[
|
||||||
|
eventName
|
||||||
|
].message
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
update(
|
||||||
|
eventName,
|
||||||
|
'message',
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
form.errors[
|
||||||
|
`templates.${eventName}.message`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={form.processing}>
|
||||||
|
<Save /> Guardar modelos
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationTemplates.layout = {
|
||||||
|
breadcrumbs: [
|
||||||
|
{ title: 'Administração', href: '/administracao/notificacoes/modelos' },
|
||||||
|
{
|
||||||
|
title: 'Modelos de email',
|
||||||
|
href: '/administracao/notificacoes/modelos',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
112
resources/js/pages/admin/reservation-policy.tsx
Normal file
112
resources/js/pages/admin/reservation-policy.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Head, useForm } from '@inertiajs/react';
|
||||||
|
import { Clock3, Save, ShieldCheck } from 'lucide-react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeading } from '@/components/page-heading';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
type Definition = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
default: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReservationPolicyPage({
|
||||||
|
definitions,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
definitions: Record<string, Definition>;
|
||||||
|
values: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
const form = useForm(values);
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
form.put('/administracao/regras', { preserveScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Regras de reserva" />
|
||||||
|
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Administração"
|
||||||
|
title="Regras de reserva"
|
||||||
|
description="Parâmetros aplicados centralmente a novos pedidos, conflitos temporais e prazos de aprovação."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900 dark:bg-sky-950/30 dark:text-sky-100">
|
||||||
|
<ShieldCheck className="mt-0.5 size-5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
As margens já gravadas permanecem associadas às reservas
|
||||||
|
existentes. Alterações entram em vigor imediatamente
|
||||||
|
para novas requisições.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{Object.entries(definitions).map(
|
||||||
|
([key, definition]) => (
|
||||||
|
<Card key={key} className="gap-4 shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Clock3 className="size-4 text-primary" />
|
||||||
|
{definition.label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="min-h-10 text-sm text-muted-foreground">
|
||||||
|
{definition.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={definition.min}
|
||||||
|
max={definition.max}
|
||||||
|
value={form.data[key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
form.setData(
|
||||||
|
key,
|
||||||
|
Number(
|
||||||
|
event.target.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-24 text-sm text-muted-foreground">
|
||||||
|
{definition.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
message={form.errors[key]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Intervalo permitido:{' '}
|
||||||
|
{definition.min}–{definition.max}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={form.processing}>
|
||||||
|
<Save /> Guardar e aplicar regras
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReservationPolicyPage.layout = {
|
||||||
|
breadcrumbs: [
|
||||||
|
{ title: 'Administração', href: '/administracao/regras' },
|
||||||
|
{ title: 'Regras de reserva', href: '/administracao/regras' },
|
||||||
|
],
|
||||||
|
};
|
||||||
423
resources/js/pages/admin/users/index.tsx
Normal file
423
resources/js/pages/admin/users/index.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||||
|
import { Plus, Search, UserCog } from 'lucide-react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeading } from '@/components/page-heading';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { Paginated } from '@/types';
|
||||||
|
|
||||||
|
type Role = { id: number; name: string; description: string | null };
|
||||||
|
type ManagedUser = {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
department: string | null;
|
||||||
|
snipe_user_id: number | null;
|
||||||
|
active: boolean;
|
||||||
|
roles: Role[];
|
||||||
|
};
|
||||||
|
type UserFormState = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
department: string;
|
||||||
|
snipeUserId: string;
|
||||||
|
password: string;
|
||||||
|
active: boolean;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
requester: 'Requisitante',
|
||||||
|
approver: 'Aprovador',
|
||||||
|
inventory_operator: 'Operador',
|
||||||
|
administrator: 'Administrador',
|
||||||
|
auditor: 'Auditor',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsersIndex({
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
users: Paginated<ManagedUser>;
|
||||||
|
roles: Role[];
|
||||||
|
filters: { search?: string; role?: string; status?: string };
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState(filters.search ?? '');
|
||||||
|
const [role, setRole] = useState(filters.role ?? '');
|
||||||
|
const [status, setStatus] = useState(filters.status ?? '');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
function submitFilters(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
router.get('/administracao/utilizadores', {
|
||||||
|
search: search || undefined,
|
||||||
|
role: role || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Utilizadores" />
|
||||||
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Administração"
|
||||||
|
title="Utilizadores e perfis"
|
||||||
|
description="Gerir acesso, responsabilidades e a associação de cada conta ao utilizador correspondente no Snipe-IT."
|
||||||
|
action={
|
||||||
|
<Button onClick={() => setCreating((value) => !value)}>
|
||||||
|
<Plus /> Novo utilizador
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Criar conta institucional</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserForm
|
||||||
|
roles={roles}
|
||||||
|
initial={{
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
department: '',
|
||||||
|
snipeUserId: '',
|
||||||
|
password: '',
|
||||||
|
active: true,
|
||||||
|
roles: ['requester'],
|
||||||
|
}}
|
||||||
|
passwordRequired
|
||||||
|
submitLabel="Criar utilizador"
|
||||||
|
onSubmit={(data, finish) =>
|
||||||
|
router.post(
|
||||||
|
'/administracao/utilizadores',
|
||||||
|
payload(data),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => setCreating(false),
|
||||||
|
onFinish: finish,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={submitFilters}
|
||||||
|
className="grid gap-2 rounded-xl border bg-card p-4 md:grid-cols-[1fr_220px_180px_auto]"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Nome, email ou departamento…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-transparent px-3 text-sm"
|
||||||
|
value={role}
|
||||||
|
onChange={(event) => setRole(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todos os perfis</option>
|
||||||
|
{roles.map((item) => (
|
||||||
|
<option key={item.id} value={item.name}>
|
||||||
|
{roleLabels[item.name] ?? item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-transparent px-3 text-sm"
|
||||||
|
value={status}
|
||||||
|
onChange={(event) => setStatus(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Ativos e inativos</option>
|
||||||
|
<option value="active">Ativos</option>
|
||||||
|
<option value="inactive">Inativos</option>
|
||||||
|
</select>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Pesquisar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
{users.data.map((user) => (
|
||||||
|
<Card key={user.id} className="gap-4 shadow-none">
|
||||||
|
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<UserCog className="size-4 text-primary" />
|
||||||
|
{user.name}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{user.email} ·{' '}
|
||||||
|
{user.department ?? 'Sem departamento'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.active
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.active ? 'Ativo' : 'Inativo'}
|
||||||
|
</Badge>
|
||||||
|
{user.roles.map((item) => (
|
||||||
|
<Badge key={item.id} variant="outline">
|
||||||
|
{roleLabels[item.name] ?? item.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-sm font-medium text-primary">
|
||||||
|
Editar conta e perfis
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4">
|
||||||
|
<UserForm
|
||||||
|
roles={roles}
|
||||||
|
initial={{
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department:
|
||||||
|
user.department ?? '',
|
||||||
|
snipeUserId:
|
||||||
|
user.snipe_user_id?.toString() ??
|
||||||
|
'',
|
||||||
|
password: '',
|
||||||
|
active: user.active,
|
||||||
|
roles: user.roles.map(
|
||||||
|
(item) => item.name,
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
submitLabel="Guardar alterações"
|
||||||
|
onSubmit={(data, finish) =>
|
||||||
|
router.put(
|
||||||
|
`/administracao/utilizadores/${user.id}`,
|
||||||
|
payload(data),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: finish,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{users.last_page > 1 && (
|
||||||
|
<nav
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
aria-label="Paginação"
|
||||||
|
>
|
||||||
|
{users.links.map((link) =>
|
||||||
|
link.url ? (
|
||||||
|
<Button
|
||||||
|
key={link.label}
|
||||||
|
asChild
|
||||||
|
variant={
|
||||||
|
link.active ? 'default' : 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: link.label,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserForm({
|
||||||
|
roles,
|
||||||
|
initial,
|
||||||
|
passwordRequired = false,
|
||||||
|
submitLabel,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
roles: Role[];
|
||||||
|
initial: UserFormState;
|
||||||
|
passwordRequired?: boolean;
|
||||||
|
submitLabel: string;
|
||||||
|
onSubmit: (data: UserFormState, finish: () => void) => void;
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState(initial);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const { errors } = usePage().props;
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setProcessing(true);
|
||||||
|
onSubmit(data, () => setProcessing(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRole(name: string) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
roles: current.roles.includes(name)
|
||||||
|
? current.roles.filter((item) => item !== name)
|
||||||
|
: [...current.roles, name],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
>
|
||||||
|
<Field label="Nome">
|
||||||
|
<Input
|
||||||
|
value={data.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setData({ ...data, name: event.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Email institucional">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setData({ ...data, email: event.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Departamento">
|
||||||
|
<Input
|
||||||
|
value={data.department}
|
||||||
|
onChange={(event) =>
|
||||||
|
setData({ ...data, department: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="ID do utilizador no Snipe-IT">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={data.snipeUserId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setData({ ...data, snipeUserId: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
passwordRequired
|
||||||
|
? 'Palavra-passe temporária'
|
||||||
|
: 'Nova palavra-passe (opcional)'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
minLength={12}
|
||||||
|
value={data.password}
|
||||||
|
onChange={(event) =>
|
||||||
|
setData({ ...data, password: event.target.value })
|
||||||
|
}
|
||||||
|
required={passwordRequired}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.active}
|
||||||
|
onChange={(event) =>
|
||||||
|
setData({ ...data, active: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Conta ativa
|
||||||
|
</label>
|
||||||
|
<fieldset className="space-y-2 md:col-span-2 xl:col-span-3">
|
||||||
|
<legend className="text-sm font-medium">Perfis</legend>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<label
|
||||||
|
key={role.id}
|
||||||
|
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.roles.includes(role.name)}
|
||||||
|
onChange={() => toggleRole(role.name)}
|
||||||
|
/>
|
||||||
|
{roleLabels[role.name] ?? role.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="space-y-1 md:col-span-2 xl:col-span-3">
|
||||||
|
{Object.values(errors).map((message, index) => (
|
||||||
|
<InputError key={`${message}-${index}`} message={message} />
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing || data.roles.length === 0}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload(data: UserFormState) {
|
||||||
|
return {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
department: data.department || null,
|
||||||
|
snipe_user_id: data.snipeUserId ? Number(data.snipeUserId) : null,
|
||||||
|
password: data.password || null,
|
||||||
|
active: data.active,
|
||||||
|
roles: data.roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
UsersIndex.layout = {
|
||||||
|
breadcrumbs: [
|
||||||
|
{ title: 'Administração', href: '/administracao/utilizadores' },
|
||||||
|
{ title: 'Utilizadores', href: '/administracao/utilizadores' },
|
||||||
|
],
|
||||||
|
};
|
||||||
186
resources/js/pages/audit/index.tsx
Normal file
186
resources/js/pages/audit/index.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import { Search, ScrollText } from 'lucide-react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PageHeading } from '@/components/page-heading';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { Paginated } from '@/types';
|
||||||
|
|
||||||
|
type AuditLog = {
|
||||||
|
id: number;
|
||||||
|
event: string;
|
||||||
|
auditable_type: string | null;
|
||||||
|
auditable_id: string | null;
|
||||||
|
before: Record<string, unknown> | null;
|
||||||
|
after: Record<string, unknown> | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
actor: { name: string; email: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuditIndex({
|
||||||
|
logs,
|
||||||
|
actors,
|
||||||
|
events,
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
logs: Paginated<AuditLog>;
|
||||||
|
actors: { id: number; name: string }[];
|
||||||
|
events: string[];
|
||||||
|
filters: {
|
||||||
|
event?: string;
|
||||||
|
actor_id?: number;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const [event, setEvent] = useState(filters.event ?? '');
|
||||||
|
const [actorId, setActorId] = useState(filters.actor_id?.toString() ?? '');
|
||||||
|
const [dateFrom, setDateFrom] = useState(filters.date_from ?? '');
|
||||||
|
const [dateTo, setDateTo] = useState(filters.date_to ?? '');
|
||||||
|
|
||||||
|
function filter(submitEvent: FormEvent) {
|
||||||
|
submitEvent.preventDefault();
|
||||||
|
router.get('/auditoria', {
|
||||||
|
event: event || undefined,
|
||||||
|
actor_id: actorId || undefined,
|
||||||
|
date_from: dateFrom || undefined,
|
||||||
|
date_to: dateTo || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Auditoria" />
|
||||||
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Controlo"
|
||||||
|
title="Registo de auditoria"
|
||||||
|
description="Histórico imutável das decisões, operações e alterações administrativas do portal."
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
onSubmit={filter}
|
||||||
|
className="grid gap-2 rounded-xl border bg-card p-4 lg:grid-cols-[1fr_220px_160px_160px_auto]"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
list="audit-events"
|
||||||
|
value={event}
|
||||||
|
onChange={(e) => setEvent(e.target.value)}
|
||||||
|
placeholder="Evento…"
|
||||||
|
/>
|
||||||
|
<datalist id="audit-events">
|
||||||
|
{events.map((item) => (
|
||||||
|
<option key={item} value={item} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-transparent px-3 text-sm"
|
||||||
|
value={actorId}
|
||||||
|
onChange={(e) => setActorId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todos os autores</option>
|
||||||
|
{actors.map((actor) => (
|
||||||
|
<option key={actor.id} value={actor.id}>
|
||||||
|
{actor.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
aria-label="Data inicial"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
aria-label="Data final"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Filtrar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<section className="space-y-3">
|
||||||
|
{logs.data.map((log) => (
|
||||||
|
<Card key={log.id} className="gap-3 py-4 shadow-none">
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="flex items-center gap-2 font-mono text-sm font-semibold">
|
||||||
|
<ScrollText className="size-4 text-primary" />
|
||||||
|
{log.event}
|
||||||
|
</p>
|
||||||
|
<time className="text-xs text-muted-foreground">
|
||||||
|
{new Intl.DateTimeFormat('pt-PT', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
}).format(new Date(log.created_at))}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{log.actor
|
||||||
|
? `${log.actor.name} · ${log.actor.email}`
|
||||||
|
: 'Sistema'}
|
||||||
|
{log.auditable_type
|
||||||
|
? ` · ${log.auditable_type} #${log.auditable_id}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
{(log.before || log.after || log.metadata) && (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-primary">
|
||||||
|
Ver dados técnicos
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-xs text-slate-100">
|
||||||
|
{JSON.stringify(
|
||||||
|
{
|
||||||
|
before: log.before,
|
||||||
|
after: log.after,
|
||||||
|
metadata: log.metadata,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
{logs.last_page > 1 && (
|
||||||
|
<nav className="flex flex-wrap gap-2">
|
||||||
|
{logs.links.map((link) =>
|
||||||
|
link.url ? (
|
||||||
|
<Button
|
||||||
|
key={link.label}
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
link.active ? 'default' : 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: link.label,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditIndex.layout = {
|
||||||
|
breadcrumbs: [{ title: 'Auditoria', href: '/auditoria' }],
|
||||||
|
};
|
||||||
194
resources/js/pages/reports/index.tsx
Normal file
194
resources/js/pages/reports/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { Head, router } from '@inertiajs/react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PageHeading } from '@/components/page-heading';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
type Summary = {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
incidents: number;
|
||||||
|
overdue: number;
|
||||||
|
utilizationHours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReportsIndex({
|
||||||
|
filters,
|
||||||
|
summary,
|
||||||
|
byStatus,
|
||||||
|
byCategory,
|
||||||
|
byDepartment,
|
||||||
|
topAssets,
|
||||||
|
}: {
|
||||||
|
filters: { date_from: string; date_to: string };
|
||||||
|
summary: Summary;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
byDepartment: Record<string, number>;
|
||||||
|
topAssets: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
const [dateFrom, setDateFrom] = useState(filters.date_from);
|
||||||
|
const [dateTo, setDateTo] = useState(filters.date_to);
|
||||||
|
|
||||||
|
function filter(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
router.get('/relatorios', { date_from: dateFrom, date_to: dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportUrl = `/relatorios/exportar?${new URLSearchParams({ date_from: dateFrom, date_to: dateTo })}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Relatórios" />
|
||||||
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Gestão e análise"
|
||||||
|
title="Relatórios operacionais"
|
||||||
|
description="Indicadores consolidados por período, com exportação detalhada para tratamento externo."
|
||||||
|
action={
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href={exportUrl}>
|
||||||
|
<Download /> Exportar CSV
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
onSubmit={filter}
|
||||||
|
className="flex flex-wrap items-end gap-3 rounded-xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
De
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDateFrom(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
Até
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(event) => setDateTo(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Atualizar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<Metric
|
||||||
|
icon={FileText}
|
||||||
|
label="Requisições"
|
||||||
|
value={summary.total}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Concluídas"
|
||||||
|
value={summary.completed}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={Clock3}
|
||||||
|
label="Horas de utilização"
|
||||||
|
value={summary.utilizationHours}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Incidentes"
|
||||||
|
value={summary.incidents}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Atrasos"
|
||||||
|
value={summary.overdue}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Breakdown title="Por estado" values={byStatus} />
|
||||||
|
<Breakdown title="Por categoria" values={byCategory} />
|
||||||
|
<Breakdown title="Por departamento" values={byDepartment} />
|
||||||
|
<Breakdown
|
||||||
|
title="Equipamentos mais requisitados"
|
||||||
|
values={topAssets}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-3 py-5 shadow-none">
|
||||||
|
<CardContent>
|
||||||
|
<Icon className="mb-3 size-5 text-primary" />
|
||||||
|
<p className="text-2xl font-semibold">{value}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Breakdown({
|
||||||
|
title,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
values: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
const max = Math.max(1, ...Object.values(values));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.keys(values).length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sem dados no período.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{Object.entries(values).map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="mb-1 flex justify-between gap-3 text-sm">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded bg-primary"
|
||||||
|
style={{ width: `${(value / max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportsIndex.layout = {
|
||||||
|
breadcrumbs: [{ title: 'Relatórios', href: '/relatorios' }],
|
||||||
|
};
|
||||||
@@ -10,9 +10,13 @@ const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
|
|||||||
export default function ReservationCalendar({
|
export default function ReservationCalendar({
|
||||||
month,
|
month,
|
||||||
reservations,
|
reservations,
|
||||||
|
scope = 'personal',
|
||||||
}: {
|
}: {
|
||||||
month: string;
|
month: string;
|
||||||
reservations: Reservation[];
|
reservations: (Reservation & {
|
||||||
|
requester?: { name: string; department: string | null };
|
||||||
|
})[];
|
||||||
|
scope?: 'personal' | 'global';
|
||||||
}) {
|
}) {
|
||||||
const [year, monthNumber] = month.split('-').map(Number);
|
const [year, monthNumber] = month.split('-').map(Number);
|
||||||
const monthStart = new Date(year, monthNumber - 1, 1);
|
const monthStart = new Date(year, monthNumber - 1, 1);
|
||||||
@@ -32,21 +36,35 @@ export default function ReservationCalendar({
|
|||||||
}).format(monthStart);
|
}).format(monthStart);
|
||||||
const previousMonth = monthKey(new Date(year, monthNumber - 2, 1));
|
const previousMonth = monthKey(new Date(year, monthNumber - 2, 1));
|
||||||
const nextMonth = monthKey(new Date(year, monthNumber, 1));
|
const nextMonth = monthKey(new Date(year, monthNumber, 1));
|
||||||
|
const global = scope === 'global';
|
||||||
|
const calendarUrl = global
|
||||||
|
? '/calendario-global'
|
||||||
|
: '/requisicoes/calendario';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="O meu calendário" />
|
<Head title={global ? 'Calendário global' : 'O meu calendário'} />
|
||||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="Planeamento pessoal"
|
eyebrow={
|
||||||
title="O meu calendário"
|
global
|
||||||
description="Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos."
|
? 'Planeamento institucional'
|
||||||
|
: 'Planeamento pessoal'
|
||||||
|
}
|
||||||
|
title={global ? 'Calendário global' : 'O meu calendário'}
|
||||||
|
description={
|
||||||
|
global
|
||||||
|
? 'Visão consolidada de todas as requisições, requisitantes e equipamentos.'
|
||||||
|
: 'Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos.'
|
||||||
|
}
|
||||||
action={
|
action={
|
||||||
<Button asChild>
|
!global ? (
|
||||||
<Link href="/requisicoes/nova">
|
<Button asChild>
|
||||||
<Plus /> Nova requisição
|
<Link href="/requisicoes/nova">
|
||||||
</Link>
|
<Plus /> Nova requisição
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -54,7 +72,7 @@ export default function ReservationCalendar({
|
|||||||
<header className="flex items-center justify-between border-b p-4">
|
<header className="flex items-center justify-between border-b p-4">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link
|
<Link
|
||||||
href={`/requisicoes/calendario?month=${previousMonth}`}
|
href={`${calendarUrl}?month=${previousMonth}`}
|
||||||
>
|
>
|
||||||
<ArrowLeft /> Anterior
|
<ArrowLeft /> Anterior
|
||||||
</Link>
|
</Link>
|
||||||
@@ -63,9 +81,7 @@ export default function ReservationCalendar({
|
|||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link
|
<Link href={`${calendarUrl}?month=${nextMonth}`}>
|
||||||
href={`/requisicoes/calendario?month=${nextMonth}`}
|
|
||||||
>
|
|
||||||
Seguinte <ArrowRight />
|
Seguinte <ArrowRight />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,6 +148,10 @@ export default function ReservationCalendar({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{reservation.reference}
|
{reservation.reference}
|
||||||
|
{global &&
|
||||||
|
reservation.requester
|
||||||
|
? ` · ${reservation.requester.name}`
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{entries.length > 3 && (
|
{entries.length > 3 && (
|
||||||
@@ -202,8 +222,5 @@ function statusBorder(status: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ReservationCalendar.layout = {
|
ReservationCalendar.layout = {
|
||||||
breadcrumbs: [
|
breadcrumbs: [{ title: 'Calendário', href: '/requisicoes/calendario' }],
|
||||||
{ title: 'Requisições', href: '/requisicoes' },
|
|
||||||
{ title: 'Calendário', href: '/requisicoes/calendario' },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ export default function CreateReservation({
|
|||||||
selectedAssetId,
|
selectedAssetId,
|
||||||
period,
|
period,
|
||||||
periodSelected,
|
periodSelected,
|
||||||
|
policy,
|
||||||
}: {
|
}: {
|
||||||
assets: Asset[];
|
assets: Asset[];
|
||||||
selectedAssetId: number;
|
selectedAssetId: number;
|
||||||
period: { starts_at?: string; ends_at?: string };
|
period: { starts_at?: string; ends_at?: string };
|
||||||
periodSelected: boolean;
|
periodSelected: boolean;
|
||||||
|
policy: {
|
||||||
|
minNoticeHours: number;
|
||||||
|
maxDurationHours: number;
|
||||||
|
turnaroundMinutes: number;
|
||||||
|
maxAssets: number;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
purpose: '',
|
purpose: '',
|
||||||
@@ -70,6 +77,15 @@ export default function CreateReservation({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<p className="rounded-xl border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Regras atuais: antecedência mínima de{' '}
|
||||||
|
{policy.minNoticeHours} h, duração máxima de{' '}
|
||||||
|
{policy.maxDurationHours} h, até {policy.maxAssets}{' '}
|
||||||
|
equipamentos
|
||||||
|
{policy.turnaroundMinutes > 0
|
||||||
|
? ` e margem operacional de ${policy.turnaroundMinutes} min.`
|
||||||
|
: '.'}
|
||||||
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
className="grid gap-6 lg:grid-cols-[1fr_320px]"
|
className="grid gap-6 lg:grid-cols-[1fr_320px]"
|
||||||
|
|||||||
3
resources/js/types/global.d.ts
vendored
3
resources/js/types/global.d.ts
vendored
@@ -20,6 +20,9 @@ declare module '@inertiajs/core' {
|
|||||||
administer: boolean;
|
administer: boolean;
|
||||||
approveReservations: boolean;
|
approveReservations: boolean;
|
||||||
operateInventory: boolean;
|
operateInventory: boolean;
|
||||||
|
viewGlobalCalendar: boolean;
|
||||||
|
viewReports: boolean;
|
||||||
|
viewAudit: boolean;
|
||||||
};
|
};
|
||||||
unreadNotifications: number;
|
unreadNotifications: number;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\NotificationTemplateController;
|
||||||
|
use App\Http\Controllers\Admin\ReservationPolicyController;
|
||||||
use App\Http\Controllers\Admin\SnipeItController;
|
use App\Http\Controllers\Admin\SnipeItController;
|
||||||
|
use App\Http\Controllers\Admin\UserController;
|
||||||
use App\Http\Controllers\ApprovalController;
|
use App\Http\Controllers\ApprovalController;
|
||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
|
use App\Http\Controllers\AuditController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
|
use App\Http\Controllers\GlobalCalendarController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use App\Http\Controllers\OperationController;
|
use App\Http\Controllers\OperationController;
|
||||||
|
use App\Http\Controllers\ReportController;
|
||||||
use App\Http\Controllers\ReservationController;
|
use App\Http\Controllers\ReservationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -43,7 +49,21 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('{reservation:uuid}/itens/{item}/incidente', [OperationController::class, 'incident'])->name('incident');
|
Route::post('{reservation:uuid}/itens/{item}/incidente', [OperationController::class, 'incident'])->name('incident');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::middleware('can:view-global-calendar')->get('calendario-global', GlobalCalendarController::class)->name('calendar.global');
|
||||||
|
Route::middleware('can:view-reports')->prefix('relatorios')->name('reports.')->group(function () {
|
||||||
|
Route::get('/', [ReportController::class, 'index'])->name('index');
|
||||||
|
Route::get('exportar', [ReportController::class, 'export'])->name('export');
|
||||||
|
});
|
||||||
|
Route::middleware('can:view-audit')->get('auditoria', [AuditController::class, 'index'])->name('audit.index');
|
||||||
|
|
||||||
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
||||||
|
Route::get('utilizadores', [UserController::class, 'index'])->name('users.index');
|
||||||
|
Route::post('utilizadores', [UserController::class, 'store'])->name('users.store');
|
||||||
|
Route::put('utilizadores/{user}', [UserController::class, 'update'])->name('users.update');
|
||||||
|
Route::get('regras', [ReservationPolicyController::class, 'edit'])->name('policy.edit');
|
||||||
|
Route::put('regras', [ReservationPolicyController::class, 'update'])->name('policy.update');
|
||||||
|
Route::get('notificacoes/modelos', [NotificationTemplateController::class, 'edit'])->name('notification-templates.edit');
|
||||||
|
Route::put('notificacoes/modelos', [NotificationTemplateController::class, 'update'])->name('notification-templates.update');
|
||||||
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
||||||
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
|
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
|
||||||
Route::post('snipe-it/sincronizar', [SnipeItController::class, 'sync'])->name('snipe-it.sync');
|
Route::post('snipe-it/sincronizar', [SnipeItController::class, 'sync'])->name('snipe-it.sync');
|
||||||
|
|||||||
50
tests/Feature/AdminNotificationTemplateTest.php
Normal file
50
tests/Feature/AdminNotificationTemplateTest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ReservationDecisionNotification;
|
||||||
|
|
||||||
|
it('personaliza modelos e aplica variáveis às notificações', function () {
|
||||||
|
$administrator = User::factory()->create();
|
||||||
|
$administrator->roles()->attach(Role::firstOrCreate(['name' => RoleName::Administrator->value]));
|
||||||
|
$requester = User::factory()->create(['name' => 'Maria Docente']);
|
||||||
|
$reservation = Reservation::create([
|
||||||
|
'reference' => 'REQ-TEMPLATE-001',
|
||||||
|
'requester_id' => $requester->id,
|
||||||
|
'purpose' => 'Validação dos modelos configuráveis.',
|
||||||
|
'usage_location' => 'Biblioteca',
|
||||||
|
'starts_at' => now()->addDay(),
|
||||||
|
'ends_at' => now()->addDay()->addHours(2),
|
||||||
|
'status' => ReservationStatus::Approved,
|
||||||
|
])->load('requester');
|
||||||
|
|
||||||
|
$templates = app(NotificationTemplateService::class)->templates();
|
||||||
|
$templates['approved'] = [
|
||||||
|
'subject' => 'Pedido {reference} confirmado',
|
||||||
|
'message' => 'Olá {requester}, o pedido {reference} foi confirmado. {comment}',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->actingAs($administrator)
|
||||||
|
->put('/administracao/notificacoes/modelos', ['templates' => $templates])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
$notification = new ReservationDecisionNotification($reservation, 'approved', 'Pode levantar às 09:00.');
|
||||||
|
$mail = $notification->toMail($requester);
|
||||||
|
$database = $notification->toArray($requester);
|
||||||
|
|
||||||
|
expect($mail->subject)->toBe('Pedido REQ-TEMPLATE-001 confirmado')
|
||||||
|
->and($mail->introLines)->toContain('Olá Maria Docente, o pedido REQ-TEMPLATE-001 foi confirmado. Pode levantar às 09:00.')
|
||||||
|
->and($database['message'])->toBe('Olá Maria Docente, o pedido REQ-TEMPLATE-001 foi confirmado. Pode levantar às 09:00.');
|
||||||
|
$this->assertDatabaseHas('audit_logs', ['event' => 'settings.notification_templates_updated']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('impede requisitantes de editar modelos de notificação', function () {
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->get('/administracao/notificacoes/modelos')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
141
tests/Feature/AdminReservationPolicyTest.php
Normal file
141
tests/Feature/AdminReservationPolicyTest.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domain\Administration\Services\ReservationPolicy;
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Domain\Reservations\Services\AvailabilityService;
|
||||||
|
use App\Domain\Reservations\Services\ReservationService;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
function policyAdministrator(): User
|
||||||
|
{
|
||||||
|
$administrator = User::factory()->create();
|
||||||
|
$role = Role::firstOrCreate(['name' => RoleName::Administrator->value]);
|
||||||
|
$administrator->roles()->attach($role);
|
||||||
|
|
||||||
|
return $administrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, int> */
|
||||||
|
function policyValues(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_replace([
|
||||||
|
'reservation.min_notice_hours' => 0,
|
||||||
|
'reservation.max_duration_hours' => 168,
|
||||||
|
'reservation.turnaround_minutes' => 0,
|
||||||
|
'reservation.approval_deadline_hours' => 48,
|
||||||
|
'reservation.max_assets' => 10,
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('permite ao administrador alterar políticas com auditoria', function () {
|
||||||
|
$administrator = policyAdministrator();
|
||||||
|
$values = policyValues([
|
||||||
|
'reservation.min_notice_hours' => 24,
|
||||||
|
'reservation.turnaround_minutes' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($administrator)
|
||||||
|
->put('/administracao/regras', $values)
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
expect(app(ReservationPolicy::class)->integer('reservation.min_notice_hours'))->toBe(24)
|
||||||
|
->and(app(ReservationPolicy::class)->integer('reservation.turnaround_minutes'))->toBe(30);
|
||||||
|
$this->assertDatabaseHas('portal_settings', [
|
||||||
|
'key' => 'reservation.min_notice_hours',
|
||||||
|
'updated_by' => $administrator->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('audit_logs', ['event' => 'settings.reservation_policy_updated']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejeita períodos fora da antecedência e duração configuradas', function () {
|
||||||
|
$administrator = policyAdministrator();
|
||||||
|
app(ReservationPolicy::class)->update(policyValues([
|
||||||
|
'reservation.min_notice_hours' => 24,
|
||||||
|
'reservation.max_duration_hours' => 4,
|
||||||
|
]), $administrator);
|
||||||
|
$requester = User::factory()->create();
|
||||||
|
$asset = SnipeAsset::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($requester)
|
||||||
|
->from('/requisicoes/nova')
|
||||||
|
->post('/requisicoes', [
|
||||||
|
'purpose' => 'Atividade audiovisual com duração demasiado longa.',
|
||||||
|
'usage_location' => 'Auditório',
|
||||||
|
'starts_at' => now()->addHours(2)->toDateTimeString(),
|
||||||
|
'ends_at' => now()->addHours(8)->toDateTimeString(),
|
||||||
|
'asset_ids' => [$asset->id],
|
||||||
|
'terms_accepted' => true,
|
||||||
|
])
|
||||||
|
->assertRedirect('/requisicoes/nova')
|
||||||
|
->assertSessionHasErrors(['starts_at', 'ends_at']);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('reservations', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aplica margem temporal na consulta e na garantia de conflito', function () {
|
||||||
|
$administrator = policyAdministrator();
|
||||||
|
app(ReservationPolicy::class)->update(policyValues([
|
||||||
|
'reservation.turnaround_minutes' => 60,
|
||||||
|
]), $administrator);
|
||||||
|
$requester = User::factory()->create();
|
||||||
|
$asset = SnipeAsset::factory()->create();
|
||||||
|
$reservation = Reservation::create([
|
||||||
|
'reference' => 'REQ-POLICY-MARGIN',
|
||||||
|
'requester_id' => $requester->id,
|
||||||
|
'purpose' => 'Reserva existente para validar a margem.',
|
||||||
|
'usage_location' => 'Estúdio',
|
||||||
|
'starts_at' => '2026-09-10 09:00:00',
|
||||||
|
'ends_at' => '2026-09-10 11:00:00',
|
||||||
|
'status' => ReservationStatus::Approved,
|
||||||
|
]);
|
||||||
|
$reservation->items()->create([
|
||||||
|
'snipe_asset_id' => $asset->id,
|
||||||
|
'starts_at' => '2026-09-10 09:00:00',
|
||||||
|
'ends_at' => '2026-09-10 11:00:00',
|
||||||
|
'buffer_starts_at' => '2026-09-10 09:00:00',
|
||||||
|
'buffer_ends_at' => '2026-09-10 11:00:00',
|
||||||
|
'status' => ReservationItemStatus::Reserved,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$availability = app(AvailabilityService::class);
|
||||||
|
expect($availability->isAvailable(
|
||||||
|
$asset,
|
||||||
|
new DateTimeImmutable('2026-09-10 11:30:00'),
|
||||||
|
new DateTimeImmutable('2026-09-10 12:30:00'),
|
||||||
|
))->toBeFalse()
|
||||||
|
->and($availability->isAvailable(
|
||||||
|
$asset,
|
||||||
|
new DateTimeImmutable('2026-09-10 12:00:00'),
|
||||||
|
new DateTimeImmutable('2026-09-10 13:00:00'),
|
||||||
|
))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grava buffers e prazo de aprovação definidos pela política', function () {
|
||||||
|
$administrator = policyAdministrator();
|
||||||
|
app(ReservationPolicy::class)->update(policyValues([
|
||||||
|
'reservation.turnaround_minutes' => 30,
|
||||||
|
'reservation.approval_deadline_hours' => 12,
|
||||||
|
]), $administrator);
|
||||||
|
$startsAt = CarbonImmutable::parse('2026-09-20 10:00:00');
|
||||||
|
$endsAt = $startsAt->addHours(2);
|
||||||
|
|
||||||
|
$reservation = app(ReservationService::class)->createPending(User::factory()->create(), [
|
||||||
|
'purpose' => 'Atividade para validar políticas configuráveis.',
|
||||||
|
'usage_location' => 'Sala multimédia',
|
||||||
|
'starts_at' => $startsAt->toDateTimeString(),
|
||||||
|
'ends_at' => $endsAt->toDateTimeString(),
|
||||||
|
'asset_ids' => [SnipeAsset::factory()->create()->id],
|
||||||
|
]);
|
||||||
|
$item = $reservation->items()->firstOrFail();
|
||||||
|
|
||||||
|
expect($item->buffer_starts_at->equalTo($startsAt->subMinutes(30)))->toBeTrue()
|
||||||
|
->and($item->buffer_ends_at->equalTo($endsAt->addMinutes(30)))->toBeTrue()
|
||||||
|
->and($reservation->created_at->diffInHours($reservation->approval_deadline))->toBe(12.0);
|
||||||
|
});
|
||||||
117
tests/Feature/AdminUserManagementTest.php
Normal file
117
tests/Feature/AdminUserManagementTest.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
|
function userManagementAdministrator(): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$role = Role::firstOrCreate(['name' => RoleName::Administrator->value]);
|
||||||
|
$user->roles()->attach($role);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureManagementRoles(): void
|
||||||
|
{
|
||||||
|
foreach (RoleName::cases() as $role) {
|
||||||
|
Role::firstOrCreate(['name' => $role->value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('restringe a gestão de utilizadores a administradores', function () {
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->get('/administracao/utilizadores')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
ensureManagementRoles();
|
||||||
|
$this->actingAs(userManagementAdministrator())
|
||||||
|
->get('/administracao/utilizadores')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/users/index')
|
||||||
|
->has('roles', count(RoleName::cases())));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cria uma conta verificada com perfis e associação Snipe-IT', function () {
|
||||||
|
ensureManagementRoles();
|
||||||
|
$administrator = userManagementAdministrator();
|
||||||
|
|
||||||
|
$this->actingAs($administrator)
|
||||||
|
->post('/administracao/utilizadores', [
|
||||||
|
'name' => 'Operadora de Inventário',
|
||||||
|
'email' => 'operadora@escola.pt',
|
||||||
|
'department' => 'Serviços de Informática',
|
||||||
|
'snipe_user_id' => 172,
|
||||||
|
'password' => 'temporaria-segura-2026',
|
||||||
|
'active' => true,
|
||||||
|
'roles' => [RoleName::Requester->value, RoleName::InventoryOperator->value],
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'operadora@escola.pt')->firstOrFail();
|
||||||
|
expect($user->email_verified_at)->not->toBeNull()
|
||||||
|
->and($user->snipe_user_id)->toBe(172)
|
||||||
|
->and($user->roles()->pluck('name')->sort()->values()->all())->toBe([
|
||||||
|
RoleName::InventoryOperator->value,
|
||||||
|
RoleName::Requester->value,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'actor_id' => $administrator->id,
|
||||||
|
'event' => 'user.created',
|
||||||
|
'auditable_id' => $user->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('atualiza dados perfis estado e palavra-passe com auditoria', function () {
|
||||||
|
ensureManagementRoles();
|
||||||
|
$administrator = userManagementAdministrator();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->roles()->attach(Role::query()->where('name', RoleName::Requester->value)->firstOrFail());
|
||||||
|
|
||||||
|
$this->actingAs($administrator)
|
||||||
|
->put("/administracao/utilizadores/{$user->id}", [
|
||||||
|
'name' => 'Utilizador Atualizado',
|
||||||
|
'email' => $user->email,
|
||||||
|
'department' => 'Direção',
|
||||||
|
'snipe_user_id' => 88,
|
||||||
|
'password' => 'nova-palavra-passe-2026',
|
||||||
|
'active' => false,
|
||||||
|
'roles' => [RoleName::Auditor->value],
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->name)->toBe('Utilizador Atualizado')
|
||||||
|
->and($user->active)->toBeFalse()
|
||||||
|
->and($user->hasRole(RoleName::Auditor))->toBeTrue()
|
||||||
|
->and(Hash::check('nova-palavra-passe-2026', $user->password))->toBeTrue();
|
||||||
|
$this->assertDatabaseHas('audit_logs', ['event' => 'user.updated', 'auditable_id' => $user->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('impede o administrador de remover o próprio acesso', function () {
|
||||||
|
ensureManagementRoles();
|
||||||
|
$administrator = userManagementAdministrator();
|
||||||
|
|
||||||
|
$this->actingAs($administrator)
|
||||||
|
->from('/administracao/utilizadores')
|
||||||
|
->put("/administracao/utilizadores/{$administrator->id}", [
|
||||||
|
'name' => $administrator->name,
|
||||||
|
'email' => $administrator->email,
|
||||||
|
'department' => $administrator->department,
|
||||||
|
'snipe_user_id' => null,
|
||||||
|
'password' => null,
|
||||||
|
'active' => false,
|
||||||
|
'roles' => [RoleName::Requester->value],
|
||||||
|
])
|
||||||
|
->assertRedirect('/administracao/utilizadores')
|
||||||
|
->assertSessionHasErrors('roles');
|
||||||
|
|
||||||
|
expect($administrator->fresh()->active)->toBeTrue()
|
||||||
|
->and($administrator->hasRole(RoleName::Administrator))->toBeTrue();
|
||||||
|
});
|
||||||
120
tests/Feature/PhaseSixReportingTest.php
Normal file
120
tests/Feature/PhaseSixReportingTest.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
|
function phaseSixUser(RoleName $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->roles()->attach(Role::firstOrCreate(['name' => $role->value]));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedReportedReservation(User $requester): Reservation
|
||||||
|
{
|
||||||
|
$asset = SnipeAsset::factory()->create([
|
||||||
|
'asset_tag' => 'REPORT-001',
|
||||||
|
'name' => 'Câmara de relatório',
|
||||||
|
'category_name' => 'Fotografia',
|
||||||
|
]);
|
||||||
|
$reservation = Reservation::create([
|
||||||
|
'reference' => 'REQ-REPORT-001',
|
||||||
|
'requester_id' => $requester->id,
|
||||||
|
'purpose' => 'Produção de conteúdos para relatório.',
|
||||||
|
'usage_location' => 'Estúdio',
|
||||||
|
'starts_at' => now()->subDays(2),
|
||||||
|
'ends_at' => now()->subDays(2)->addHours(3),
|
||||||
|
'status' => ReservationStatus::Completed,
|
||||||
|
]);
|
||||||
|
$reservation->items()->create([
|
||||||
|
'snipe_asset_id' => $asset->id,
|
||||||
|
'starts_at' => $reservation->starts_at,
|
||||||
|
'ends_at' => $reservation->ends_at,
|
||||||
|
'buffer_starts_at' => $reservation->starts_at,
|
||||||
|
'buffer_ends_at' => $reservation->ends_at,
|
||||||
|
'status' => ReservationItemStatus::Returned,
|
||||||
|
'delivered_at' => $reservation->starts_at,
|
||||||
|
'returned_at' => $reservation->ends_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('apresenta o calendário global apenas a perfis autorizados', function () {
|
||||||
|
$requester = phaseSixUser(RoleName::Requester);
|
||||||
|
$reservation = completedReportedReservation($requester);
|
||||||
|
$month = $reservation->starts_at->format('Y-m');
|
||||||
|
|
||||||
|
$this->actingAs($requester)->get("/calendario-global?month={$month}")->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs(phaseSixUser(RoleName::Approver))
|
||||||
|
->get("/calendario-global?month={$month}")
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('reservations/calendar')
|
||||||
|
->where('scope', 'global')
|
||||||
|
->has('reservations', 1)
|
||||||
|
->where('reservations.0.reference', 'REQ-REPORT-001')
|
||||||
|
->where('reservations.0.requester.id', $requester->id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calcula indicadores e exporta detalhe em CSV', function () {
|
||||||
|
$requester = User::factory()->create([
|
||||||
|
'name' => '=HYPERLINK("https://invalid.test")',
|
||||||
|
'department' => 'Comunicação',
|
||||||
|
]);
|
||||||
|
completedReportedReservation($requester);
|
||||||
|
$auditor = phaseSixUser(RoleName::Auditor);
|
||||||
|
$query = http_build_query([
|
||||||
|
'date_from' => now()->subDays(7)->toDateString(),
|
||||||
|
'date_to' => now()->toDateString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($auditor)
|
||||||
|
->get("/relatorios?{$query}")
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('reports/index')
|
||||||
|
->where('summary.total', 1)
|
||||||
|
->where('summary.completed', 1)
|
||||||
|
->where('summary.utilizationHours', 3)
|
||||||
|
->where('byCategory.Fotografia', 1)
|
||||||
|
->where('byDepartment.Comunicação', 1));
|
||||||
|
|
||||||
|
$response = $this->actingAs($auditor)->get("/relatorios/exportar?{$query}");
|
||||||
|
$response->assertOk()->assertDownload();
|
||||||
|
expect($response->streamedContent())->toContain(
|
||||||
|
'REQ-REPORT-001',
|
||||||
|
'REPORT-001',
|
||||||
|
'Comunicação',
|
||||||
|
"'=HYPERLINK",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disponibiliza auditoria imutável a auditores e administradores', function () {
|
||||||
|
$actor = User::factory()->create();
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'event' => 'reservation.test_audit',
|
||||||
|
'metadata' => ['reference' => 'REQ-AUDIT-001'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())->get('/auditoria')->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs(phaseSixUser(RoleName::Auditor))
|
||||||
|
->get('/auditoria?event=test_audit')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('audit/index')
|
||||||
|
->has('logs.data', 1)
|
||||||
|
->where('logs.data.0.event', 'reservation.test_audit')
|
||||||
|
->where('logs.data.0.actor.name', $actor->name));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user