feat: complete administration and reporting
This commit is contained in:
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,
|
||||
'starts_at' => $lockedItem->starts_at,
|
||||
'ends_at' => $lockedItem->ends_at,
|
||||
'buffer_starts_at' => $lockedItem->buffer_starts_at,
|
||||
'buffer_ends_at' => $lockedItem->buffer_ends_at,
|
||||
'status' => ReservationItemStatus::Held,
|
||||
'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
|
||||
{
|
||||
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',
|
||||
'condition_out', 'condition_in', 'notes',
|
||||
];
|
||||
@@ -30,6 +30,8 @@ class ReservationItem extends Model
|
||||
'status' => ReservationItemStatus::class,
|
||||
'starts_at' => 'immutable_datetime',
|
||||
'ends_at' => 'immutable_datetime',
|
||||
'buffer_starts_at' => 'immutable_datetime',
|
||||
'buffer_ends_at' => 'immutable_datetime',
|
||||
'delivered_at' => 'immutable_datetime',
|
||||
'returned_at' => 'immutable_datetime',
|
||||
];
|
||||
|
||||
@@ -2,24 +2,30 @@
|
||||
|
||||
namespace App\Domain\Reservations\Services;
|
||||
|
||||
use App\Domain\Administration\Services\ReservationPolicy;
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AvailabilityService
|
||||
{
|
||||
public function __construct(private readonly ReservationPolicy $policy) {}
|
||||
|
||||
/** @return Builder<SnipeAsset> */
|
||||
public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder
|
||||
{
|
||||
[$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt);
|
||||
|
||||
return SnipeAsset::query()
|
||||
->where('active', true)
|
||||
->where('requestable', true)
|
||||
->whereNull('assigned_id')
|
||||
->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query
|
||||
->whereIn('status', ReservationItemStatus::blockingValues())
|
||||
->where('starts_at', '<', $endsAt)
|
||||
->where('ends_at', '>', $startsAt))
|
||||
->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt])
|
||||
->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt]))
|
||||
->whereDoesntHave('blocks', fn (Builder $query): Builder => $query
|
||||
->where('starts_at', '<', $endsAt)
|
||||
->where('ends_at', '>', $startsAt));
|
||||
@@ -35,11 +41,12 @@ class AvailabilityService
|
||||
return false;
|
||||
}
|
||||
|
||||
[$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt);
|
||||
$hasReservationConflict = $asset->reservationItems()
|
||||
->whereIn('status', ReservationItemStatus::blockingValues())
|
||||
->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId))
|
||||
->where('starts_at', '<', $endsAt)
|
||||
->where('ends_at', '>', $startsAt)
|
||||
->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt])
|
||||
->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt])
|
||||
->exists();
|
||||
|
||||
if ($hasReservationConflict) {
|
||||
@@ -51,4 +58,15 @@ class AvailabilityService
|
||||
->where('ends_at', '>', $startsAt)
|
||||
->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;
|
||||
|
||||
use App\Domain\Administration\Services\ReservationPolicy;
|
||||
use App\Domain\Approvals\Models\ApprovalAction;
|
||||
use App\Domain\Audit\Models\AuditLog;
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
@@ -20,7 +21,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
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
|
||||
@@ -29,8 +33,10 @@ class ReservationService
|
||||
{
|
||||
$startsAt = CarbonImmutable::parse($data['starts_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()
|
||||
->whereKey($data['asset_ids'])
|
||||
->lockForUpdate()
|
||||
@@ -58,7 +64,7 @@ class ReservationService
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'status' => ReservationStatus::PendingApproval,
|
||||
'approval_deadline' => now()->addHours(48),
|
||||
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
|
||||
]);
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
@@ -66,6 +72,8 @@ class ReservationService
|
||||
'snipe_asset_id' => $asset->id,
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'buffer_starts_at' => $bufferStartsAt,
|
||||
'buffer_ends_at' => $bufferEndsAt,
|
||||
'status' => ReservationItemStatus::Held,
|
||||
]);
|
||||
}
|
||||
@@ -156,7 +164,7 @@ class ReservationService
|
||||
|
||||
$locked->update([
|
||||
'status' => ReservationStatus::PendingApproval,
|
||||
'approval_deadline' => now()->addHours(48),
|
||||
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
|
||||
]);
|
||||
ApprovalAction::create([
|
||||
'reservation_id' => $locked->id,
|
||||
@@ -258,4 +266,21 @@ class ReservationService
|
||||
|
||||
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;
|
||||
|
||||
use App\Domain\Administration\Services\ReservationPolicy;
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
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([
|
||||
'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,
|
||||
'period' => $period,
|
||||
'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,
|
||||
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? 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,
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Domain\Administration\Services\ReservationPolicy;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreReservationRequest extends FormRequest
|
||||
{
|
||||
@@ -14,14 +17,39 @@ class StoreReservationRequest extends FormRequest
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
$maxAssets = app(ReservationPolicy::class)->integer('reservation.max_assets');
|
||||
|
||||
return [
|
||||
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'usage_location' => ['required', 'string', 'max:255'],
|
||||
'starts_at' => ['required', 'date', 'after:now'],
|
||||
'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'],
|
||||
'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 $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'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -28,10 +29,11 @@ class ReservationDecisionNotification extends Notification implements ShouldQueu
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = $this->template();
|
||||
$message = (new MailMessage)
|
||||
->subject($this->subject())
|
||||
->subject($template['subject'])
|
||||
->greeting("Atualização da requisição {$this->reservation->reference}")
|
||||
->line($this->message());
|
||||
->line($template['message']);
|
||||
|
||||
if (filled($this->comment)) {
|
||||
$message->line("Comentário: {$this->comment}");
|
||||
@@ -43,35 +45,21 @@ class ReservationDecisionNotification extends Notification implements ShouldQueu
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$template = $this->template();
|
||||
|
||||
return [
|
||||
'reservation_uuid' => $this->reservation->uuid,
|
||||
'reference' => $this->reservation->reference,
|
||||
'event' => $this->event,
|
||||
'message' => $this->message(),
|
||||
'message' => $template['message'],
|
||||
'comment' => $this->comment,
|
||||
'url' => '/requisicoes',
|
||||
];
|
||||
}
|
||||
|
||||
private function subject(): string
|
||||
/** @return array{subject:string,message:string} */
|
||||
private function template(): array
|
||||
{
|
||||
return match ($this->event) {
|
||||
'approved' => "Requisição {$this->reservation->reference} aprovada",
|
||||
'rejected' => "Requisição {$this->reservation->reference} rejeitada",
|
||||
'information_requested' => "Esclarecimentos necessários para {$this->reservation->reference}",
|
||||
'asset_substituted' => "Equipamento alterado em {$this->reservation->reference}",
|
||||
default => "Atualização da requisição {$this->reservation->reference}",
|
||||
};
|
||||
}
|
||||
|
||||
private function message(): string
|
||||
{
|
||||
return match ($this->event) {
|
||||
'approved' => 'A sua requisição foi aprovada.',
|
||||
'rejected' => 'A sua requisição foi rejeitada.',
|
||||
'information_requested' => 'O aprovador pediu informações adicionais.',
|
||||
'asset_substituted' => 'Um equipamento da sua requisição foi substituído por outro equivalente.',
|
||||
default => 'O estado da sua requisição foi atualizado.',
|
||||
};
|
||||
return app(NotificationTemplateService::class)->render($this->event, $this->reservation, $this->comment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -27,28 +28,27 @@ class ReservationSubmittedNotification extends Notification implements ShouldQue
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = app(NotificationTemplateService::class)->render($this->event, $this->reservation);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($this->event === 'information_provided'
|
||||
? "Resposta recebida para {$this->reservation->reference}"
|
||||
: "Nova requisição {$this->reservation->reference}")
|
||||
->subject($template['subject'])
|
||||
->greeting($this->event === 'information_provided'
|
||||
? 'O requisitante respondeu ao pedido de esclarecimentos'
|
||||
: 'Nova requisição para aprovação')
|
||||
->line("{$this->reservation->requester->name} submeteu a requisição {$this->reservation->reference} para decisão.")
|
||||
->line("Período: {$this->reservation->starts_at->format('d/m/Y H:i')} — {$this->reservation->ends_at->format('d/m/Y H:i')}.")
|
||||
->line($template['message'])
|
||||
->action('Analisar requisição', url('/aprovacoes'));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$template = app(NotificationTemplateService::class)->render($this->event, $this->reservation);
|
||||
|
||||
return [
|
||||
'reservation_uuid' => $this->reservation->uuid,
|
||||
'reference' => $this->reservation->reference,
|
||||
'event' => $this->event,
|
||||
'message' => $this->event === 'information_provided'
|
||||
? "Foram prestados esclarecimentos para {$this->reservation->reference}."
|
||||
: "Nova requisição {$this->reservation->reference} aguarda decisão.",
|
||||
'message' => $template['message'],
|
||||
'url' => '/aprovacoes',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,6 +42,25 @@ class AppServiceProvider extends ServiceProvider
|
||||
fn (User $user): bool => $user->active
|
||||
&& ($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)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user