feat: complete administration and reporting

This commit is contained in:
2026-08-04 17:40:52 +01:00
parent d829f34580
commit 7a4ac48aaf
42 changed files with 2595 additions and 76 deletions

View File

@@ -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.');
}
}

View 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.');
}
}

View 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;
}
}

View 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,
]);
}
}

View 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(),
]);
}
}

View 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;
}
}

View File

@@ -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'),
],
]);
}

View File

@@ -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',

View File

@@ -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.");
}
}];
}
}