@@ -5,6 +5,7 @@ APP_DEBUG=true
|
|||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8000
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
|
INERTIA_SSR_ENABLED=false
|
||||||
INERTIA_SSR_HOT_URL=http://node:5173
|
INERTIA_SSR_HOT_URL=http://node:5173
|
||||||
|
|
||||||
APP_LOCALE=pt_PT
|
APP_LOCALE=pt_PT
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -47,6 +47,17 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
|||||||
- Endpoints públicos de liveness/readiness sem divulgação de detalhes internos e health check do Nginx.
|
- Endpoints públicos de liveness/readiness sem divulgação de detalhes internos e health check do Nginx.
|
||||||
- Scripts operacionais para backup PostgreSQL custom, checksum e teste de restauro numa base temporária isolada.
|
- Scripts operacionais para backup PostgreSQL custom, checksum e teste de restauro numa base temporária isolada.
|
||||||
- Checklist versionado de entrada em produção com evidências e critérios de go/no-go.
|
- Checklist versionado de entrada em produção com evidências e critérios de go/no-go.
|
||||||
|
- Endpoint `/requisicoes/verificar-disponibilidade-semanal` para verificar disponibilidade de equipamentos baseado em slots semanais para reservas recorrentes.
|
||||||
|
- Botão "Verificar equipamentos livres" visível quando a repetição semanal está ativa, permitindo filtrar o catálogo pela próxima ocorrência semanal.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Validação de formulário de requisição: erros de validação agora aparecem individualmente por baixo de cada campo em vez de num toast/alert genérico.
|
||||||
|
- Removido listener global de erros do Inertia que interferia com o fluxo normal de validação.
|
||||||
|
- Adicionado `preserveState: true` e `preserveScroll: true` aos POSTs do formulário para manter o estado após erros de validação.
|
||||||
|
- Modal de detalhes do dia nos calendários pessoal e global, mostrando todas as requisições de um dia com estado, horário, equipamentos e requisitante (no calendário global).
|
||||||
|
- Fuso horário da aplicação alterado de UTC para Europe/Lisbon para garantir consistência com o PHP e evitar discrepâncias de horário entre frontend e backend.
|
||||||
|
- Botão "Verificar equipamentos livres" no formulário de requisição quando a repetição semanal está ativa, permitindo filtrar o catálogo pela próxima ocorrência semanal.
|
||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ class Reservation extends Model
|
|||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'uuid', 'reference', 'requester_id', 'purpose', 'usage_location', 'starts_at',
|
'uuid', 'reference', 'requester_id', 'recurring_group_id', 'recurrence_pattern',
|
||||||
|
'purpose', 'usage_location', 'starts_at',
|
||||||
'ends_at', 'status', 'approval_deadline', 'approved_by', 'approved_at',
|
'ends_at', 'status', 'approval_deadline', 'approved_by', 'approved_at',
|
||||||
'rejected_by', 'rejected_at', 'decision_reason', 'cancelled_by',
|
'rejected_by', 'rejected_at', 'decision_reason', 'cancelled_by',
|
||||||
'cancelled_at', 'cancellation_reason',
|
'cancelled_at', 'cancellation_reason',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Models\User;
|
|||||||
use App\Notifications\ReservationSubmittedNotification;
|
use App\Notifications\ReservationSubmittedNotification;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -258,6 +259,396 @@ class ReservationService
|
|||||||
return $expired;
|
return $expired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $assetIds
|
||||||
|
* @return array{
|
||||||
|
* occurrences: list<array{
|
||||||
|
* index: int,
|
||||||
|
* date_formatted: string,
|
||||||
|
* time_formatted: string,
|
||||||
|
* starts_at: string,
|
||||||
|
* ends_at: string,
|
||||||
|
* has_conflict: bool,
|
||||||
|
* conflicted_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null, alternatives: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>}>,
|
||||||
|
* available_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>,
|
||||||
|
* }>,
|
||||||
|
* total_occurrences: int,
|
||||||
|
* conflicts_count: int,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function checkRecurringSeries(
|
||||||
|
array $assetIds,
|
||||||
|
CarbonImmutable $startsAt,
|
||||||
|
CarbonImmutable $endsAt,
|
||||||
|
CarbonImmutable $repeatUntil,
|
||||||
|
): array {
|
||||||
|
$assets = SnipeAsset::query()
|
||||||
|
->whereKey($assetIds)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$occurrences = [];
|
||||||
|
$curStartsAt = $startsAt;
|
||||||
|
$curEndsAt = $endsAt;
|
||||||
|
$durationMinutes = $startsAt->diffInMinutes($endsAt);
|
||||||
|
$conflictsTotal = 0;
|
||||||
|
$index = 0;
|
||||||
|
|
||||||
|
while ($curStartsAt->lte($repeatUntil) && $index < 26) {
|
||||||
|
$conflictedAssets = [];
|
||||||
|
$availableAssets = [];
|
||||||
|
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
if ($this->availability->isAvailable($asset, $curStartsAt, $curEndsAt)) {
|
||||||
|
$availableAssets[] = [
|
||||||
|
'id' => $asset->id,
|
||||||
|
'name' => $asset->name,
|
||||||
|
'asset_tag' => $asset->asset_tag,
|
||||||
|
'model_name' => $asset->model_name,
|
||||||
|
'category_name' => $asset->category_name,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$alternativesQuery = $this->availability->availableAssets($curStartsAt, $curEndsAt)
|
||||||
|
->whereNotIn('id', $assetIds);
|
||||||
|
|
||||||
|
if ($asset->category_name !== null) {
|
||||||
|
$alternativesQuery->where('category_name', $asset->category_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||||
|
$alternatives = $alternativesQuery
|
||||||
|
->limit(8)
|
||||||
|
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||||
|
->map(fn (SnipeAsset $alt): array => [
|
||||||
|
'id' => $alt->id,
|
||||||
|
'name' => $alt->name,
|
||||||
|
'asset_tag' => $alt->asset_tag,
|
||||||
|
'model_name' => $alt->model_name,
|
||||||
|
'category_name' => $alt->category_name,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if (empty($alternatives)) {
|
||||||
|
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||||
|
$alternatives = $this->availability->availableAssets($curStartsAt, $curEndsAt)
|
||||||
|
->whereNotIn('id', $assetIds)
|
||||||
|
->limit(6)
|
||||||
|
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||||
|
->map(fn (SnipeAsset $alt): array => [
|
||||||
|
'id' => $alt->id,
|
||||||
|
'name' => $alt->name,
|
||||||
|
'asset_tag' => $alt->asset_tag,
|
||||||
|
'model_name' => $alt->model_name,
|
||||||
|
'category_name' => $alt->category_name,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflictedAssets[] = [
|
||||||
|
'id' => $asset->id,
|
||||||
|
'name' => $asset->name,
|
||||||
|
'asset_tag' => $asset->asset_tag,
|
||||||
|
'model_name' => $asset->model_name,
|
||||||
|
'category_name' => $asset->category_name,
|
||||||
|
'alternatives' => $alternatives,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasConflict = count($conflictedAssets) > 0;
|
||||||
|
if ($hasConflict) {
|
||||||
|
$conflictsTotal++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayOfWeekPt = match ($curStartsAt->dayOfWeek) {
|
||||||
|
0 => 'Domingo',
|
||||||
|
1 => 'Segunda-feira',
|
||||||
|
2 => 'Terça-feira',
|
||||||
|
3 => 'Quarta-feira',
|
||||||
|
4 => 'Quinta-feira',
|
||||||
|
5 => 'Sexta-feira',
|
||||||
|
6 => 'Sábado',
|
||||||
|
default => 'Dia',
|
||||||
|
};
|
||||||
|
|
||||||
|
$occurrences[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'date_formatted' => sprintf('%s, %s', $dayOfWeekPt, $curStartsAt->format('d/m/Y')),
|
||||||
|
'time_formatted' => sprintf('%s às %s', $curStartsAt->format('H:i'), $curEndsAt->format('H:i')),
|
||||||
|
'starts_at' => $curStartsAt->toIso8601String(),
|
||||||
|
'ends_at' => $curEndsAt->toIso8601String(),
|
||||||
|
'has_conflict' => $hasConflict,
|
||||||
|
'conflicted_assets' => $conflictedAssets,
|
||||||
|
'available_assets' => $availableAssets,
|
||||||
|
];
|
||||||
|
|
||||||
|
$curStartsAt = $curStartsAt->addWeek();
|
||||||
|
$curEndsAt = $curStartsAt->addMinutes($durationMinutes);
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'occurrences' => $occurrences,
|
||||||
|
'total_occurrences' => count($occurrences),
|
||||||
|
'conflicts_count' => $conflictsTotal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $assetIds
|
||||||
|
* @param list<array{day_of_week: int, start_time: string, end_time: string}> $weeklySlots
|
||||||
|
* @return array{
|
||||||
|
* occurrences: list<array{
|
||||||
|
* index: int,
|
||||||
|
* date_formatted: string,
|
||||||
|
* time_formatted: string,
|
||||||
|
* starts_at: string,
|
||||||
|
* ends_at: string,
|
||||||
|
* has_conflict: bool,
|
||||||
|
* conflicted_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null, alternatives: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>}>,
|
||||||
|
* available_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>,
|
||||||
|
* }>,
|
||||||
|
* total_occurrences: int,
|
||||||
|
* conflicts_count: int,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function checkFlexibleRecurringSeries(
|
||||||
|
array $assetIds,
|
||||||
|
array $weeklySlots,
|
||||||
|
CarbonImmutable $repeatUntil,
|
||||||
|
): array {
|
||||||
|
$assets = SnipeAsset::query()
|
||||||
|
->whereKey($assetIds)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$occurrences = [];
|
||||||
|
$conflictsTotal = 0;
|
||||||
|
$index = 0;
|
||||||
|
$currentDate = now()->startOfDay();
|
||||||
|
|
||||||
|
$dayNames = [
|
||||||
|
1 => 'Segunda-feira',
|
||||||
|
2 => 'Terça-feira',
|
||||||
|
3 => 'Quarta-feira',
|
||||||
|
4 => 'Quinta-feira',
|
||||||
|
5 => 'Sexta-feira',
|
||||||
|
];
|
||||||
|
|
||||||
|
while ($currentDate->lte($repeatUntil) && $index < 26) {
|
||||||
|
foreach ($weeklySlots as $slot) {
|
||||||
|
if ($currentDate->dayOfWeek !== $slot['day_of_week']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startsAt = $currentDate->setTimeFromTimeString($slot['start_time']);
|
||||||
|
$endsAt = $currentDate->setTimeFromTimeString($slot['end_time']);
|
||||||
|
|
||||||
|
if ($startsAt->lt(now()) || $startsAt->gt($repeatUntil)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflictedAssets = [];
|
||||||
|
$availableAssets = [];
|
||||||
|
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
if ($this->availability->isAvailable($asset, $startsAt, $endsAt)) {
|
||||||
|
$availableAssets[] = [
|
||||||
|
'id' => $asset->id,
|
||||||
|
'name' => $asset->name,
|
||||||
|
'asset_tag' => $asset->asset_tag,
|
||||||
|
'model_name' => $asset->model_name,
|
||||||
|
'category_name' => $asset->category_name,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$alternativesQuery = $this->availability->availableAssets($startsAt, $endsAt)
|
||||||
|
->whereNotIn('id', $assetIds);
|
||||||
|
|
||||||
|
if ($asset->category_name !== null) {
|
||||||
|
$alternativesQuery->where('category_name', $asset->category_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||||
|
$alternatives = $alternativesQuery
|
||||||
|
->limit(8)
|
||||||
|
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||||
|
->map(fn (SnipeAsset $alt): array => [
|
||||||
|
'id' => $alt->id,
|
||||||
|
'name' => $alt->name,
|
||||||
|
'asset_tag' => $alt->asset_tag,
|
||||||
|
'model_name' => $alt->model_name,
|
||||||
|
'category_name' => $alt->category_name,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if (empty($alternatives)) {
|
||||||
|
/** @var list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $alternatives */
|
||||||
|
$alternatives = $this->availability->availableAssets($startsAt, $endsAt)
|
||||||
|
->whereNotIn('id', $assetIds)
|
||||||
|
->limit(6)
|
||||||
|
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name'])
|
||||||
|
->map(fn (SnipeAsset $alt): array => [
|
||||||
|
'id' => $alt->id,
|
||||||
|
'name' => $alt->name,
|
||||||
|
'asset_tag' => $alt->asset_tag,
|
||||||
|
'model_name' => $alt->model_name,
|
||||||
|
'category_name' => $alt->category_name,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflictedAssets[] = [
|
||||||
|
'id' => $asset->id,
|
||||||
|
'name' => $asset->name,
|
||||||
|
'asset_tag' => $asset->asset_tag,
|
||||||
|
'model_name' => $asset->model_name,
|
||||||
|
'category_name' => $asset->category_name,
|
||||||
|
'alternatives' => $alternatives,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasConflict = count($conflictedAssets) > 0;
|
||||||
|
if ($hasConflict) {
|
||||||
|
$conflictsTotal++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayOfWeekPt = $dayNames[$slot['day_of_week']] ?? 'Dia';
|
||||||
|
|
||||||
|
$occurrences[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'date_formatted' => sprintf('%s, %s', $dayOfWeekPt, $startsAt->format('d/m/Y')),
|
||||||
|
'time_formatted' => sprintf('%s às %s', $startsAt->format('H:i'), $endsAt->format('H:i')),
|
||||||
|
'starts_at' => $startsAt->toIso8601String(),
|
||||||
|
'ends_at' => $endsAt->toIso8601String(),
|
||||||
|
'has_conflict' => $hasConflict,
|
||||||
|
'conflicted_assets' => $conflictedAssets,
|
||||||
|
'available_assets' => $availableAssets,
|
||||||
|
];
|
||||||
|
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentDate = $currentDate->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'occurrences' => $occurrences,
|
||||||
|
'total_occurrences' => count($occurrences),
|
||||||
|
'conflicts_count' => $conflictsTotal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{purpose: string, usage_location: string} $baseData
|
||||||
|
* @param list<array{starts_at: string, ends_at: string, asset_ids: list<int>}> $occurrences
|
||||||
|
* @return Collection<int, Reservation>
|
||||||
|
*/
|
||||||
|
public function createRecurringPending(User $requester, array $baseData, array $occurrences): Collection
|
||||||
|
{
|
||||||
|
if (empty($occurrences)) {
|
||||||
|
throw ValidationException::withMessages(['occurrences' => 'Deve selecionar pelo menos um dia para a requisição recorrente.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($occurrences) > 26) {
|
||||||
|
throw ValidationException::withMessages(['occurrences' => 'A série recorrente não pode exceder 26 semanas.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($occurrences as $occ) {
|
||||||
|
$startsAt = CarbonImmutable::parse($occ['starts_at']);
|
||||||
|
$endsAt = CarbonImmutable::parse($occ['ends_at']);
|
||||||
|
$this->validatePolicy($startsAt, $endsAt, count($occ['asset_ids']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$recurringGroupId = (string) Str::uuid();
|
||||||
|
|
||||||
|
/** @var Collection<int, Reservation> $reservations */
|
||||||
|
$reservations = DB::transaction(function () use ($requester, $baseData, $occurrences, $recurringGroupId): Collection {
|
||||||
|
$created = collect();
|
||||||
|
|
||||||
|
foreach ($occurrences as $occ) {
|
||||||
|
$startsAt = CarbonImmutable::parse($occ['starts_at']);
|
||||||
|
$endsAt = CarbonImmutable::parse($occ['ends_at']);
|
||||||
|
[$bufferStartsAt, $bufferEndsAt] = $this->availability->bufferedPeriod($startsAt, $endsAt);
|
||||||
|
|
||||||
|
$assets = SnipeAsset::query()
|
||||||
|
->whereKey($occ['asset_ids'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($assets->count() !== count(array_unique($occ['asset_ids']))) {
|
||||||
|
throw ValidationException::withMessages(['occurrences' => "Um ou mais equipamentos na data {$startsAt->format('d/m/Y')} já não estão disponíveis."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$unavailable = $assets->filter(
|
||||||
|
fn (SnipeAsset $asset): bool => ! $this->availability->isAvailable($asset, $startsAt, $endsAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($unavailable->isNotEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'occurrences' => "Conflito na data {$startsAt->format('d/m/Y')}: {$unavailable->pluck('name')->join(', ')}.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation = Reservation::create([
|
||||||
|
'reference' => $this->nextReference(),
|
||||||
|
'requester_id' => $requester->id,
|
||||||
|
'recurring_group_id' => $recurringGroupId,
|
||||||
|
'recurrence_pattern' => 'weekly',
|
||||||
|
'purpose' => $baseData['purpose'],
|
||||||
|
'usage_location' => $baseData['usage_location'],
|
||||||
|
'starts_at' => $startsAt,
|
||||||
|
'ends_at' => $endsAt,
|
||||||
|
'status' => ReservationStatus::PendingApproval,
|
||||||
|
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
$reservation->items()->create([
|
||||||
|
'snipe_asset_id' => $asset->id,
|
||||||
|
'starts_at' => $startsAt,
|
||||||
|
'ends_at' => $endsAt,
|
||||||
|
'buffer_starts_at' => $bufferStartsAt,
|
||||||
|
'buffer_ends_at' => $bufferEndsAt,
|
||||||
|
'status' => ReservationItemStatus::Held,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $requester->id,
|
||||||
|
'event' => 'reservation.recurring_submitted',
|
||||||
|
'auditable_type' => $reservation->getMorphClass(),
|
||||||
|
'auditable_id' => $reservation->id,
|
||||||
|
'after' => $reservation->only(['reference', 'purpose', 'usage_location', 'starts_at', 'ends_at', 'status', 'recurring_group_id', 'recurrence_pattern']),
|
||||||
|
'metadata' => [
|
||||||
|
'asset_ids' => $assets->modelKeys(),
|
||||||
|
'recurring_group_id' => $recurringGroupId,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$created->push($reservation->load('requester', 'items.asset'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $created;
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
$approvers = User::query()
|
||||||
|
->where('active', true)
|
||||||
|
->whereHas('roles', fn ($query) => $query->whereIn('name', [
|
||||||
|
RoleName::Approver->value,
|
||||||
|
RoleName::Administrator->value,
|
||||||
|
]))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($reservations as $reservation) {
|
||||||
|
Notification::send($approvers, new ReservationSubmittedNotification($reservation));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reservations;
|
||||||
|
}
|
||||||
|
|
||||||
private function nextReference(): string
|
private function nextReference(): string
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -12,20 +12,33 @@ class GlobalCalendarController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
{
|
{
|
||||||
$filters = $request->validate(['month' => ['nullable', 'date_format:Y-m']]);
|
$filters = $request->validate([
|
||||||
|
'month' => ['nullable', 'date_format:Y-m'],
|
||||||
|
'status' => ['nullable', 'string', 'in:pending,confirmed,closed'],
|
||||||
|
]);
|
||||||
$month = isset($filters['month'])
|
$month = isset($filters['month'])
|
||||||
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
|
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
|
||||||
: now()->startOfMonth();
|
: now()->startOfMonth();
|
||||||
|
|
||||||
|
$query = Reservation::query()
|
||||||
|
->with(['requester:id,name,department', 'items.asset:id,name,asset_tag'])
|
||||||
|
->where('starts_at', '<', $month->addMonth())
|
||||||
|
->where('ends_at', '>', $month);
|
||||||
|
|
||||||
|
if (! empty($filters['status'])) {
|
||||||
|
$statusMap = [
|
||||||
|
'pending' => ['pending_approval', 'awaiting_information'],
|
||||||
|
'confirmed' => ['approved', 'ready', 'delivered', 'in_use'],
|
||||||
|
'closed' => ['rejected', 'cancelled', 'expired', 'no_show', 'completed'],
|
||||||
|
];
|
||||||
|
$query->whereIn('status', $statusMap[$filters['status']]);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('reservations/calendar', [
|
return Inertia::render('reservations/calendar', [
|
||||||
'scope' => 'global',
|
'scope' => 'global',
|
||||||
'month' => $month->format('Y-m'),
|
'month' => $month->format('Y-m'),
|
||||||
'reservations' => Reservation::query()
|
'reservations' => $query->orderBy('starts_at')->get(),
|
||||||
->with(['requester:id,name,department', 'items.asset:id,name,asset_tag'])
|
'status' => $filters['status'] ?? '',
|
||||||
->where('starts_at', '<', $month->addMonth())
|
|
||||||
->where('ends_at', '>', $month)
|
|
||||||
->orderBy('starts_at')
|
|
||||||
->get(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ 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;
|
||||||
use App\Domain\Reservations\Services\ReservationService;
|
use App\Domain\Reservations\Services\ReservationService;
|
||||||
|
use App\Http\Requests\StoreRecurringReservationRequest;
|
||||||
use App\Http\Requests\StoreReservationRequest;
|
use App\Http\Requests\StoreReservationRequest;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -18,11 +22,20 @@ class ReservationController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$filters = $request->validate([
|
||||||
|
'status' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = $request->user()->reservations()
|
||||||
|
->with('items.asset:id,name,asset_tag');
|
||||||
|
|
||||||
|
if (! empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('reservations/index', [
|
return Inertia::render('reservations/index', [
|
||||||
'reservations' => $request->user()->reservations()
|
'reservations' => $query->latest()->paginate(15),
|
||||||
->with('items.asset:id,name,asset_tag')
|
'status' => $filters['status'] ?? '',
|
||||||
->latest()
|
|
||||||
->paginate(15),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,20 +43,31 @@ class ReservationController extends Controller
|
|||||||
{
|
{
|
||||||
$filters = $request->validate([
|
$filters = $request->validate([
|
||||||
'month' => ['nullable', 'date_format:Y-m'],
|
'month' => ['nullable', 'date_format:Y-m'],
|
||||||
|
'status' => ['nullable', 'string', 'in:pending,confirmed,closed'],
|
||||||
]);
|
]);
|
||||||
$month = isset($filters['month'])
|
$month = isset($filters['month'])
|
||||||
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
|
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
|
||||||
: now()->startOfMonth();
|
: now()->startOfMonth();
|
||||||
$windowEnd = $month->addMonth();
|
$windowEnd = $month->addMonth();
|
||||||
|
|
||||||
|
$query = $request->user()->reservations()
|
||||||
|
->with('items.asset:id,name,asset_tag')
|
||||||
|
->where('starts_at', '<', $windowEnd)
|
||||||
|
->where('ends_at', '>', $month);
|
||||||
|
|
||||||
|
if (! empty($filters['status'])) {
|
||||||
|
$statusMap = [
|
||||||
|
'pending' => ['pending_approval', 'awaiting_information'],
|
||||||
|
'confirmed' => ['approved', 'ready', 'delivered', 'in_use'],
|
||||||
|
'closed' => ['rejected', 'cancelled', 'expired', 'no_show', 'completed'],
|
||||||
|
];
|
||||||
|
$query->whereIn('status', $statusMap[$filters['status']]);
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('reservations/calendar', [
|
return Inertia::render('reservations/calendar', [
|
||||||
'month' => $month->format('Y-m'),
|
'month' => $month->format('Y-m'),
|
||||||
'reservations' => $request->user()->reservations()
|
'reservations' => $query->orderBy('starts_at')->get(),
|
||||||
->with('items.asset:id,name,asset_tag')
|
'status' => $filters['status'] ?? '',
|
||||||
->where('starts_at', '<', $windowEnd)
|
|
||||||
->where('ends_at', '>', $month)
|
|
||||||
->orderBy('starts_at')
|
|
||||||
->get(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +116,225 @@ class ReservationController extends Controller
|
|||||||
return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação.");
|
return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function checkRecurrence(Request $request, ReservationService $service): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'weekly_slots' => ['required', 'array', 'min:1', 'max:5'],
|
||||||
|
'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'weekly_slots.*.start_time' => ['required', 'date_format:H:i'],
|
||||||
|
'weekly_slots.*.end_time' => ['required', 'date_format:H:i'],
|
||||||
|
'repeat_until' => ['nullable', 'date', 'after:now'],
|
||||||
|
'weeks_count' => ['nullable', 'integer', 'min:1', 'max:26'],
|
||||||
|
'asset_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'asset_ids.*' => ['required', 'integer', 'exists:snipe_assets,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Custom validation: end_time must be after start_time for each slot
|
||||||
|
foreach ($data['weekly_slots'] as $index => $slot) {
|
||||||
|
if ($slot['start_time'] >= $slot['end_time']) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array{weekly_slots: list<array{day_of_week: int, start_time: string, end_time: string}>, repeat_until: string|null, weeks_count: int|null, asset_ids: list<int>} $data */
|
||||||
|
$repeatUntil = ! empty($data['repeat_until'])
|
||||||
|
? CarbonImmutable::parse($data['repeat_until'])->setTime(23, 59, 59)
|
||||||
|
: now()->addWeeks(($data['weeks_count'] ?? 4) - 1)->endOfDay();
|
||||||
|
|
||||||
|
$result = $service->checkFlexibleRecurringSeries(
|
||||||
|
$data['asset_ids'],
|
||||||
|
$data['weekly_slots'],
|
||||||
|
$repeatUntil,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkWeeklyAvailability(Request $request, AvailabilityService $availability): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'weekly_slots' => ['required', 'array', 'min:1', 'max:5'],
|
||||||
|
'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'weekly_slots.*.start_time' => ['required', 'date_format:H:i'],
|
||||||
|
'weekly_slots.*.end_time' => ['required', 'date_format:H:i'],
|
||||||
|
'week_offset' => ['nullable', 'integer', 'min:0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Custom validation: end_time must be after start_time for each slot
|
||||||
|
foreach ($data['weekly_slots'] as $index => $slot) {
|
||||||
|
if ($slot['start_time'] >= $slot['end_time']) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$weekOffset = $data['week_offset'] ?? 0;
|
||||||
|
$now = now();
|
||||||
|
$startOfWeek = $now->copy()->addWeeks($weekOffset)->startOfWeek(CarbonImmutable::MONDAY);
|
||||||
|
|
||||||
|
// Calculate specific dates for each slot
|
||||||
|
$slotPeriods = [];
|
||||||
|
foreach ($data['weekly_slots'] as $slot) {
|
||||||
|
$slotDate = $startOfWeek->copy()->addDays($slot['day_of_week'] - 1);
|
||||||
|
$startsAt = $slotDate->setTimeFromTimeString($slot['start_time']);
|
||||||
|
$endsAt = $slotDate->setTimeFromTimeString($slot['end_time']);
|
||||||
|
$slotPeriods[] = ['starts_at' => $startsAt, 'ends_at' => $endsAt];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find assets available for ALL slots (intersection)
|
||||||
|
$availableAssets = null;
|
||||||
|
foreach ($slotPeriods as $period) {
|
||||||
|
$assetsForSlot = $availability->availableAssets($period['starts_at'], $period['ends_at'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name'])
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
if ($availableAssets === null) {
|
||||||
|
$availableAssets = $assetsForSlot;
|
||||||
|
} else {
|
||||||
|
// Intersect: keep only assets available in both sets
|
||||||
|
$availableAssets = $availableAssets->intersectByKeys($assetsForSlot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'assets' => $availableAssets ? $availableAssets->values() : collect(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkSlotAvailability(Request $request, AvailabilityService $availability): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'weekly_slots' => ['required', 'array'],
|
||||||
|
'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'weekly_slots.*.start_time' => ['required', 'date_format:H:i'],
|
||||||
|
'weekly_slots.*.end_time' => ['required', 'date_format:H:i'],
|
||||||
|
'week_offset' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'asset_ids' => ['nullable', 'array'],
|
||||||
|
'asset_ids.*' => ['nullable', 'integer', 'exists:snipe_assets,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Custom validation: end_time must be after start_time for each slot
|
||||||
|
foreach ($data['weekly_slots'] as $index => $slot) {
|
||||||
|
if ($slot['start_time'] >= $slot['end_time']) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$weekOffset = $data['week_offset'] ?? 0;
|
||||||
|
$startOfWeek = $now->copy()->addWeeks($weekOffset)->startOfWeek(CarbonImmutable::MONDAY);
|
||||||
|
$availabilityMap = [];
|
||||||
|
|
||||||
|
foreach ($data['weekly_slots'] as $slot) {
|
||||||
|
// Calculate the date based on week offset
|
||||||
|
$slotDate = $startOfWeek->copy()->addDays($slot['day_of_week'] - 1);
|
||||||
|
$startsAt = $slotDate->setTimeFromTimeString($slot['start_time']);
|
||||||
|
$endsAt = $slotDate->setTimeFromTimeString($slot['end_time']);
|
||||||
|
|
||||||
|
$key = "{$slot['day_of_week']}-{$slot['start_time']}-{$slot['end_time']}";
|
||||||
|
|
||||||
|
// Check availability for this specific slot
|
||||||
|
$availableAssets = $availability->availableAssets($startsAt, $endsAt);
|
||||||
|
|
||||||
|
// If specific asset_ids are provided, check if any of them are available
|
||||||
|
if (! empty($data['asset_ids'])) {
|
||||||
|
$availableIds = $availableAssets->pluck('id')->toArray();
|
||||||
|
$hasAvailable = false;
|
||||||
|
foreach ($data['asset_ids'] as $assetId) {
|
||||||
|
if (in_array($assetId, $availableIds)) {
|
||||||
|
$hasAvailable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$availabilityMap[$key] = $hasAvailable;
|
||||||
|
} else {
|
||||||
|
// If no specific assets, check if any asset is available
|
||||||
|
$availabilityMap[$key] = $availableAssets->count() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'availability' => $availabilityMap,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeRecurring(StoreRecurringReservationRequest $request, ReservationService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
/** @var array{purpose: string, usage_location: string} $baseData */
|
||||||
|
$baseData = $request->safe()->only(['purpose', 'usage_location']);
|
||||||
|
/** @var list<array{starts_at: string, ends_at: string, asset_ids: list<int>}> $occurrences */
|
||||||
|
$occurrences = $request->validated('occurrences');
|
||||||
|
|
||||||
|
$reservations = $service->createRecurringPending($request->user(), $baseData, $occurrences);
|
||||||
|
$count = $reservations->count();
|
||||||
|
|
||||||
|
return to_route('dashboard')->with('success', "Série recorrente submetida com {$count} requisições para aprovação.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeMultiple(Request $request, ReservationService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'weekly_slots' => ['required', 'array', 'min:1'],
|
||||||
|
'weekly_slots.*.day_of_week' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'weekly_slots.*.start_time' => ['required', 'date_format:H:i'],
|
||||||
|
'weekly_slots.*.end_time' => ['required', 'date_format:H:i'],
|
||||||
|
'week_offset' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
|
||||||
|
'usage_location' => ['required', 'string', 'max:255'],
|
||||||
|
'asset_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'asset_ids.*' => ['required', 'integer', 'distinct', 'exists:snipe_assets,id'],
|
||||||
|
'terms_accepted' => ['accepted'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Custom validation: end_time must be after start_time for each slot
|
||||||
|
foreach ($data['weekly_slots'] as $index => $slot) {
|
||||||
|
if ($slot['start_time'] >= $slot['end_time']) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"weekly_slots.{$index}.end_time" => 'A hora de fim deve ser posterior à hora de início.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$weekOffset = $data['week_offset'] ?? 0;
|
||||||
|
$startOfWeek = $now->copy()->addWeeks($weekOffset)->startOfWeek(CarbonImmutable::MONDAY);
|
||||||
|
$baseData = [
|
||||||
|
'purpose' => $data['purpose'],
|
||||||
|
'usage_location' => $data['usage_location'],
|
||||||
|
'asset_ids' => $data['asset_ids'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$reservations = collect();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($now, $weekOffset, $startOfWeek, $data, $baseData, $service, $request, &$reservations) {
|
||||||
|
foreach ($data['weekly_slots'] as $slot) {
|
||||||
|
// Calculate the date based on week offset
|
||||||
|
$slotDate = $startOfWeek->copy()->addDays($slot['day_of_week'] - 1);
|
||||||
|
$startsAt = $slotDate->setTimeFromTimeString($slot['start_time']);
|
||||||
|
$endsAt = $slotDate->setTimeFromTimeString($slot['end_time']);
|
||||||
|
|
||||||
|
$slotData = array_merge($baseData, [
|
||||||
|
'starts_at' => $startsAt->toIso8601String(),
|
||||||
|
'ends_at' => $endsAt->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reservation = $service->createPending($request->user(), $slotData);
|
||||||
|
$reservations->push($reservation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$count = $reservations->count();
|
||||||
|
$references = $reservations->pluck('reference')->join(', ');
|
||||||
|
|
||||||
|
return to_route('dashboard')->with('success', "Criadas {$count} requisições ({$references}) para aprovação.");
|
||||||
|
}
|
||||||
|
|
||||||
public function cancel(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse
|
public function cancel(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ class HandleInertiaRequests extends Middleware
|
|||||||
],
|
],
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'success' => fn () => $request->session()->get('success'),
|
'success' => fn () => $request->session()->get('success'),
|
||||||
'error' => fn () => $request->session()->get('error'),
|
// Não partilhar flash.error quando há erros de validação na sessão:
|
||||||
|
// os erros de campo são tratados pelo Inertia diretamente via form.errors
|
||||||
|
// e devem aparecer abaixo dos campos, não como toast global.
|
||||||
|
'error' => fn () => $request->session()->has('errors')
|
||||||
|
? null
|
||||||
|
: $request->session()->get('error'),
|
||||||
],
|
],
|
||||||
'can' => [
|
'can' => [
|
||||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
||||||
|
|||||||
28
app/Http/Requests/StoreRecurringReservationRequest.php
Normal file
28
app/Http/Requests/StoreRecurringReservationRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreRecurringReservationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->active === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'purpose' => ['required', 'string', 'min:10', 'max:2000'],
|
||||||
|
'usage_location' => ['required', 'string', 'max:255'],
|
||||||
|
'terms_accepted' => ['accepted'],
|
||||||
|
'occurrences' => ['required', 'array', 'min:1', 'max:26'],
|
||||||
|
'occurrences.*.starts_at' => ['required', 'date', 'after:now'],
|
||||||
|
'occurrences.*.ends_at' => ['required', 'date', 'after:occurrences.*.starts_at'],
|
||||||
|
'occurrences.*.asset_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'occurrences.*.asset_ids.*' => ['required', 'integer', 'exists:snipe_assets,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ class StoreReservationRequest extends FormRequest
|
|||||||
public function after(): array
|
public function after(): array
|
||||||
{
|
{
|
||||||
return [function (Validator $validator): void {
|
return [function (Validator $validator): void {
|
||||||
|
$now = now();
|
||||||
|
$nowFormatted = $now->format('d/m/Y \à\s H:i');
|
||||||
|
|
||||||
|
if ($validator->errors()->has('starts_at')) {
|
||||||
|
$validator->errors()->add('starts_at', "A data de início deve ser posterior às {$nowFormatted}.");
|
||||||
|
}
|
||||||
|
|
||||||
if ($validator->errors()->hasAny(['starts_at', 'ends_at'])) {
|
if ($validator->errors()->hasAny(['starts_at', 'ends_at'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
'timezone' => 'Europe/Lisbon',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'ssr' => [
|
'ssr' => [
|
||||||
'enabled' => true,
|
'enabled' => env('INERTIA_SSR_ENABLED', false),
|
||||||
'url' => 'http://127.0.0.1:13714',
|
'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),
|
||||||
'hot_url' => env('INERTIA_SSR_HOT_URL'),
|
'hot_url' => env('INERTIA_SSR_HOT_URL'),
|
||||||
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('reservations', function (Blueprint $table) {
|
||||||
|
$table->uuid('recurring_group_id')->nullable()->index()->after('requester_id');
|
||||||
|
$table->string('recurrence_pattern')->nullable()->after('recurring_group_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reservations', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['recurring_group_id']);
|
||||||
|
$table->dropColumn(['recurring_group_id', 'recurrence_pattern']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Estado do projeto e handoff
|
# Estado do projeto e handoff
|
||||||
|
|
||||||
**Atualizado em:** 5 de agosto de 2026
|
**Atualizado em:** 15 de outubro de 2026
|
||||||
**Fase:** Fases 1 a 6 concluídas; Fase 7 em curso (identidade, saúde, recuperação e ligação de leitura ao Snipe-IT de produção concluídas)
|
**Fase:** Fases 1 a 6 concluídas; Fase 7 em curso (identidade, saúde, recuperação e ligação de leitura ao Snipe-IT de produção concluídas)
|
||||||
**Último agente:** Codex
|
**Último agente:** Devin
|
||||||
|
|
||||||
## Resultado disponível
|
## Resultado disponível
|
||||||
|
|
||||||
@@ -59,6 +59,10 @@
|
|||||||
- Health check Docker do Nginx sobre readiness.
|
- Health check Docker do Nginx sobre readiness.
|
||||||
- Scripts operacionais de backup PostgreSQL custom com checksum e restauro de teste numa base temporária isolada.
|
- Scripts operacionais de backup PostgreSQL custom com checksum e restauro de teste numa base temporária isolada.
|
||||||
- Checklist versionado de produção, incluindo Entra, Snipe-IT, SMTP/TLS, segredos, monitorização, recuperação, segurança e piloto.
|
- Checklist versionado de produção, incluindo Entra, Snipe-IT, SMTP/TLS, segredos, monitorização, recuperação, segurança e piloto.
|
||||||
|
- Melhoria na validação do formulário de requisição: erros de validação são agora acumulados e mostrados simultaneamente em vez de parar no primeiro erro.
|
||||||
|
- Validação limpa erros de campos já corrigidos ao clicar em submeter, mantendo apenas os erros dos campos ainda incorretos.
|
||||||
|
- Endpoint `/requisicoes/verificar-disponibilidade-semanal` para verificar disponibilidade de equipamentos baseado em slots semanais.
|
||||||
|
- Botão "Verificar equipamentos livres" visível quando a repetição semanal está ativa, permitindo filtrar o catálogo pela próxima ocorrência semanal.
|
||||||
|
|
||||||
## Limites conhecidos
|
## Limites conhecidos
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@
|
|||||||
|
|
||||||
## Próxima tarefa recomendada
|
## Próxima tarefa recomendada
|
||||||
|
|
||||||
Implementar a **Fase 7 — hardening e entrada em produção**:
|
Continuar com a **Fase 7 — hardening e entrada em produção**:
|
||||||
|
|
||||||
1. configurar e validar Microsoft Entra no tenant institucional, incluindo Conditional Access/MFA;
|
1. configurar e validar Microsoft Entra no tenant institucional, incluindo Conditional Access/MFA;
|
||||||
2. confirmar com o responsável Snipe-IT a versão, o âmbito de menor privilégio dos tokens e as categorias/estados requisitáveis; validar checkout/check-in apenas no primeiro movimento institucional legítimo;
|
2. confirmar com o responsável Snipe-IT a versão, o âmbito de menor privilégio dos tokens e as categorias/estados requisitáveis; validar checkout/check-in apenas no primeiro movimento institucional legítimo;
|
||||||
@@ -84,44 +88,25 @@ Implementar a **Fase 7 — hardening e entrada em produção**:
|
|||||||
|
|
||||||
## Testes da última sessão
|
## Testes da última sessão
|
||||||
|
|
||||||
Validação concluída em 5 de agosto de 2026:
|
Validação concluída em 15 de outubro de 2026:
|
||||||
|
- TypeScript: passou
|
||||||
|
- ESLint: passou
|
||||||
|
- Vite build: passou
|
||||||
|
- Validação de formulário corrigida: removido listener global de erros do Inertia e adicionado preserveState/preserveScroll para garantir que erros aparecem inline por baixo de cada campo
|
||||||
|
|
||||||
|
Validação concluída em 14 de outubro de 2026:
|
||||||
|
|
||||||
- `docker compose exec -T app composer test`: passou;
|
- `docker compose exec -T app composer test`: passou;
|
||||||
- Laravel Pint: 141 ficheiros sem desvios;
|
- Laravel Pint: 147 ficheiros sem desvios;
|
||||||
- PHPStan/Larastan nível 7: 107 ficheiros, zero erros;
|
- PHPStan/Larastan nível 7: 109 ficheiros, zero erros;
|
||||||
- Pest: 110 testes passaram, 617 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
- Pest: 113 testes passaram, 638 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, 2314 módulos cliente e 103 módulos SSR transformados;
|
- Vite: builds cliente e SSR concluídos;
|
||||||
- PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal;
|
- Modal de detalhes do dia nos calendários pessoal e global implementado, mostrando todas as requisições de um dia com estado, horário, equipamentos e requisitante;
|
||||||
- PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
|
- Fuso horário da aplicação alterado de UTC para Europe/Lisbon para garantir consistência com o PHP e evitar discrepâncias de horário entre frontend e backend;
|
||||||
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
|
- Correção da conversão de data no frontend para usar fuso horário local em vez de UTC, evitando desvios de horário na data de repetição.
|
||||||
- PostgreSQL 17: migration `notifications` aplicada com sucesso;
|
|
||||||
- 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;
|
|
||||||
- Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`;
|
|
||||||
- Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia;
|
|
||||||
- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; aprovações, esclarecimentos e notificações foram validados por testes Inertia, TypeScript e builds cliente/SSR.
|
|
||||||
- testes focados de Microsoft Entra e health checks: 9 passaram, com 57 asserções;
|
|
||||||
- PHPStan/Larastan depois da integração OIDC: 107 ficheiros, zero erros;
|
|
||||||
- TypeScript, ESLint e Prettier depois das alterações de login/segurança: passaram;
|
|
||||||
- backup PostgreSQL real em formato custom criado com checksum e validado por `pg_restore --list`;
|
|
||||||
- restauro real numa base temporária: passou e confirmou 12 migrations antes da remoção automática;
|
|
||||||
- smoke tests via Nginx: `/health/live` devolveu `ok` e `/health/ready` devolveu `ready`.
|
|
||||||
- diagnóstico de login local anterior à ligação de produção: a base de desenvolvimento estava vazia apesar de migrada; `db:seed --force` repôs 2 utilizadores ativos, 5 perfis e, nesse contexto sem Snipe-IT configurado, 18 ativos fictícios;
|
|
||||||
- login real no browser validado com `demo@escola.pt`: redirecionou para `/dashboard` e não produziu erros de consola.
|
|
||||||
- testes focados das salvaguardas Snipe-IT: 17 passaram, com 106 asserções, incluindo prova de ausência de `POST` em ativos atribuídos e check-in sem proveniência ou destinatário válidos;
|
|
||||||
- tokens Snipe-IT confirmados como configurados e distintos; ambos responderam a uma consulta `GET` com sucesso, sem revelar credenciais ou URL;
|
|
||||||
- sincronização real exclusivamente de leitura concluída: 2367 ativos processados e criados no cache local, sem qualquer pedido de escrita no Snipe-IT;
|
|
||||||
- cache local após sincronização: 2367 ativos ativos, 716 atribuídos e 144 marcados como requisitáveis;
|
|
||||||
- frontend: TypeScript, ESLint, Prettier e build Vite passaram novamente; Composer Audit sem avisos conhecidos;
|
|
||||||
- smoke tests após a sincronização: liveness `ok`, readiness `ready` e `/login` HTTP 200.
|
|
||||||
- nova recuperação do login após deteção de 0 utilizadores/0 perfis: seeder tornado idempotente e restrito a `local`/`testing`; repôs 2 utilizadores e 5 perfis sem alterar os 2367 ativos reais no cache;
|
|
||||||
- login administrativo validado de ponta a ponta no browser integrado: `admin@escola.pt` redirecionou para `/dashboard` e apresentou as áreas administrativas;
|
|
||||||
- testes específicos do seeder: reposição repetida sem duplicados e preservação do cache Snipe-IT configurado; suite total com 110 testes e 617 asserções;
|
|
||||||
- regra permanente adicionada: testes Laravel usam `composer test`/SQLite em memória e nunca `php artisan test` diretamente sobre a configuração PostgreSQL local.
|
|
||||||
|
|
||||||
## Decisões pendentes do contexto institucional
|
## Decisões pendentes do contexto institucional
|
||||||
|
|
||||||
|
|||||||
170
lang/pt/validation.php
Normal file
170
lang/pt/validation.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'accepted' => 'O campo :attribute deve ser aceite.',
|
||||||
|
'active_url' => 'O campo :attribute não é um URL válido.',
|
||||||
|
'after' => 'O campo :attribute deve ser uma data posterior à data atual.',
|
||||||
|
'after_or_equal' => 'O campo :attribute deve ser uma data posterior ou igual à data atual.',
|
||||||
|
'alpha' => 'O campo :attribute só pode conter letras.',
|
||||||
|
'alpha_dash' => 'O campo :attribute só pode conter letras, números e traços.',
|
||||||
|
'alpha_num' => 'O campo :attribute só pode conter letras e números.',
|
||||||
|
'array' => 'O campo :attribute deve ser um array.',
|
||||||
|
'before' => 'O campo :attribute deve ser uma data anterior a :date.',
|
||||||
|
'before_or_equal' => 'O campo :attribute deve ser uma data anterior ou igual a :date.',
|
||||||
|
'between' => [
|
||||||
|
'numeric' => 'O campo :attribute deve estar entre :min e :max.',
|
||||||
|
'file' => 'O campo :attribute deve ter entre :min e :max kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter entre :min e :max caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter entre :min e :max itens.',
|
||||||
|
],
|
||||||
|
'boolean' => 'O campo :attribute deve ser verdadeiro ou falso.',
|
||||||
|
'confirmed' => 'A confirmação do campo :attribute não coincide.',
|
||||||
|
'current_password' => 'A palavra-passe está incorreta.',
|
||||||
|
'date' => 'O campo :attribute não é uma data válida.',
|
||||||
|
'date_equals' => 'O campo :attribute deve ser uma data igual a :date.',
|
||||||
|
'date_format' => 'O campo :attribute não corresponde ao formato :format.',
|
||||||
|
'declined' => 'O campo :attribute deve ser rejeitado.',
|
||||||
|
'declined_if' => 'O campo :attribute deve ser rejeitado quando :other é :value.',
|
||||||
|
'different' => 'O campo :attribute e :other devem ser diferentes.',
|
||||||
|
'digits' => 'O campo :attribute deve ter :digits dígitos.',
|
||||||
|
'digits_between' => 'O campo :attribute deve ter entre :min e :max dígitos.',
|
||||||
|
'dimensions' => 'O campo :attribute tem dimensões de imagem inválidas.',
|
||||||
|
'distinct' => 'O campo :attribute tem um valor duplicado.',
|
||||||
|
'email' => 'O campo :attribute deve ser um endereço de email válido.',
|
||||||
|
'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values.',
|
||||||
|
'enum' => 'O :attribute selecionado é inválido.',
|
||||||
|
'exists' => 'O :attribute selecionado é inválido.',
|
||||||
|
'file' => 'O campo :attribute deve ser um ficheiro.',
|
||||||
|
'filled' => 'O campo :attribute é obrigatório.',
|
||||||
|
'gt' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser maior que :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter mais de :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter mais de :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter mais de :value itens.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser maior ou igual a :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter pelo menos :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter pelo menos :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter pelo menos :value itens.',
|
||||||
|
],
|
||||||
|
'image' => 'O campo :attribute deve ser uma imagem.',
|
||||||
|
'in' => 'O :attribute selecionado é inválido.',
|
||||||
|
'in_array' => 'O campo :attribute não existe em :other.',
|
||||||
|
'integer' => 'O campo :attribute deve ser um número inteiro.',
|
||||||
|
'ip' => 'O campo :attribute deve ser um endereço IP válido.',
|
||||||
|
'ipv4' => 'O campo :attribute deve ser um endereço IPv4 válido.',
|
||||||
|
'ipv6' => 'O campo :attribute deve ser um endereço IPv6 válido.',
|
||||||
|
'json' => 'O campo :attribute deve ser uma string JSON válida.',
|
||||||
|
'lt' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser menor que :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter menos de :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter menos de :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter menos de :value itens.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser menor ou igual a :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter no máximo :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter no máximo :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter no máximo :value itens.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.',
|
||||||
|
'max' => [
|
||||||
|
'numeric' => 'O campo :attribute não pode ser superior a :max.',
|
||||||
|
'file' => 'O campo :attribute não pode ter mais de :max kilobytes.',
|
||||||
|
'string' => 'O campo :attribute não pode ter mais de :max caracteres.',
|
||||||
|
'array' => 'O campo :attribute não pode ter mais de :max itens.',
|
||||||
|
],
|
||||||
|
'mimes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.',
|
||||||
|
'mimetypes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.',
|
||||||
|
'min' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser pelo menos :min.',
|
||||||
|
'file' => 'O campo :attribute deve ter pelo menos :min kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter pelo menos :min caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter pelo menos :min itens.',
|
||||||
|
],
|
||||||
|
'multiple_of' => 'O campo :attribute deve ser múltiplo de :value.',
|
||||||
|
'not_in' => 'O :attribute selecionado é inválido.',
|
||||||
|
'not_regex' => 'O formato do campo :attribute é inválido.',
|
||||||
|
'numeric' => 'O campo :attribute deve ser um número.',
|
||||||
|
'password' => 'A palavra-passe está incorreta.',
|
||||||
|
'present' => 'O campo :attribute deve estar presente.',
|
||||||
|
'prohibited' => 'O campo :attribute é proibido.',
|
||||||
|
'prohibited_if' => 'O campo :attribute é proibido quando :other é :value.',
|
||||||
|
'prohibited_unless' => 'O campo :attribute é proibido a menos que :other esteja em :values.',
|
||||||
|
'prohibits' => 'O campo :attribute proíbe :other de estar presente.',
|
||||||
|
'regex' => 'O formato do campo :attribute é inválido.',
|
||||||
|
'required' => 'Este campo é obrigatório.',
|
||||||
|
'required_array_keys' => 'O campo :attribute deve conter as chaves: :values.',
|
||||||
|
'required_if' => 'Este campo é obrigatório quando :other é :value.',
|
||||||
|
'required_unless' => 'Este campo é obrigatório a menos que :other esteja em :values.',
|
||||||
|
'required_with' => 'Este campo é obrigatório quando :values está presente.',
|
||||||
|
'required_with_all' => 'Este campo é obrigatório quando :values estão presentes.',
|
||||||
|
'required_without' => 'Este campo é obrigatório quando :values não está presente.',
|
||||||
|
'required_without_all' => 'Este campo é obrigatório quando nenhum de :values está presente.',
|
||||||
|
'same' => 'O campo :attribute e :other devem coincidir.',
|
||||||
|
'size' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser :size.',
|
||||||
|
'file' => 'O campo :attribute deve ter :size kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter :size caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve conter :size itens.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'O campo :attribute deve começar com um dos seguintes: :values.',
|
||||||
|
'string' => 'O campo :attribute deve ser uma string.',
|
||||||
|
'timezone' => 'O campo :attribute deve ser um fuso horário válido.',
|
||||||
|
'unique' => 'O :attribute já está a ser utilizado.',
|
||||||
|
'uploaded' => 'O falha no upload do campo :attribute.',
|
||||||
|
'url' => 'O campo :attribute deve ser um URL válido.',
|
||||||
|
'uuid' => 'O campo :attribute deve ser um UUID válido.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'starts_at.after' => 'A data de início deve ser posterior à data atual.',
|
||||||
|
'starts_at.after_or_equal' => 'A data de início deve ser posterior ou igual à data atual.',
|
||||||
|
'ends_at.after' => 'A data de fim deve ser posterior à data de início.',
|
||||||
|
'ends_at.after_or_equal' => 'A data de fim deve ser posterior ou igual à data atual.',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [
|
||||||
|
'purpose' => 'finalidade',
|
||||||
|
'usage_location' => 'local de utilização',
|
||||||
|
'starts_at' => 'data de início',
|
||||||
|
'ends_at' => 'data de fim',
|
||||||
|
'asset_ids' => 'equipamentos',
|
||||||
|
'terms_accepted' => 'aceitação dos termos',
|
||||||
|
'email' => 'email',
|
||||||
|
'password' => 'palavra-passe',
|
||||||
|
],
|
||||||
|
];
|
||||||
170
lang/pt_PT/validation.php
Normal file
170
lang/pt_PT/validation.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'accepted' => 'O campo :attribute deve ser aceite.',
|
||||||
|
'active_url' => 'O campo :attribute não é um URL válido.',
|
||||||
|
'after' => 'O campo :attribute deve ser uma data posterior à data atual.',
|
||||||
|
'after_or_equal' => 'O campo :attribute deve ser uma data posterior ou igual à data atual.',
|
||||||
|
'alpha' => 'O campo :attribute só pode conter letras.',
|
||||||
|
'alpha_dash' => 'O campo :attribute só pode conter letras, números e traços.',
|
||||||
|
'alpha_num' => 'O campo :attribute só pode conter letras e números.',
|
||||||
|
'array' => 'O campo :attribute deve ser um array.',
|
||||||
|
'before' => 'O campo :attribute deve ser uma data anterior a :date.',
|
||||||
|
'before_or_equal' => 'O campo :attribute deve ser uma data anterior ou igual a :date.',
|
||||||
|
'between' => [
|
||||||
|
'numeric' => 'O campo :attribute deve estar entre :min e :max.',
|
||||||
|
'file' => 'O campo :attribute deve ter entre :min e :max kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter entre :min e :max caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter entre :min e :max itens.',
|
||||||
|
],
|
||||||
|
'boolean' => 'O campo :attribute deve ser verdadeiro ou falso.',
|
||||||
|
'confirmed' => 'A confirmação do campo :attribute não coincide.',
|
||||||
|
'current_password' => 'A palavra-passe está incorreta.',
|
||||||
|
'date' => 'O campo :attribute não é uma data válida.',
|
||||||
|
'date_equals' => 'O campo :attribute deve ser uma data igual a :date.',
|
||||||
|
'date_format' => 'O campo :attribute não corresponde ao formato :format.',
|
||||||
|
'declined' => 'O campo :attribute deve ser rejeitado.',
|
||||||
|
'declined_if' => 'O campo :attribute deve ser rejeitado quando :other é :value.',
|
||||||
|
'different' => 'O campo :attribute e :other devem ser diferentes.',
|
||||||
|
'digits' => 'O campo :attribute deve ter :digits dígitos.',
|
||||||
|
'digits_between' => 'O campo :attribute deve ter entre :min e :max dígitos.',
|
||||||
|
'dimensions' => 'O campo :attribute tem dimensões de imagem inválidas.',
|
||||||
|
'distinct' => 'O campo :attribute tem um valor duplicado.',
|
||||||
|
'email' => 'O campo :attribute deve ser um endereço de email válido.',
|
||||||
|
'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values.',
|
||||||
|
'enum' => 'O :attribute selecionado é inválido.',
|
||||||
|
'exists' => 'O :attribute selecionado é inválido.',
|
||||||
|
'file' => 'O campo :attribute deve ser um ficheiro.',
|
||||||
|
'filled' => 'O campo :attribute é obrigatório.',
|
||||||
|
'gt' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser maior que :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter mais de :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter mais de :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter mais de :value itens.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser maior ou igual a :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter pelo menos :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter pelo menos :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter pelo menos :value itens.',
|
||||||
|
],
|
||||||
|
'image' => 'O campo :attribute deve ser uma imagem.',
|
||||||
|
'in' => 'O :attribute selecionado é inválido.',
|
||||||
|
'in_array' => 'O campo :attribute não existe em :other.',
|
||||||
|
'integer' => 'O campo :attribute deve ser um número inteiro.',
|
||||||
|
'ip' => 'O campo :attribute deve ser um endereço IP válido.',
|
||||||
|
'ipv4' => 'O campo :attribute deve ser um endereço IPv4 válido.',
|
||||||
|
'ipv6' => 'O campo :attribute deve ser um endereço IPv6 válido.',
|
||||||
|
'json' => 'O campo :attribute deve ser uma string JSON válida.',
|
||||||
|
'lt' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser menor que :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter menos de :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter menos de :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter menos de :value itens.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser menor ou igual a :value.',
|
||||||
|
'file' => 'O campo :attribute deve ter no máximo :value kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter no máximo :value caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter no máximo :value itens.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.',
|
||||||
|
'max' => [
|
||||||
|
'numeric' => 'O campo :attribute não pode ser superior a :max.',
|
||||||
|
'file' => 'O campo :attribute não pode ter mais de :max kilobytes.',
|
||||||
|
'string' => 'O campo :attribute não pode ter mais de :max caracteres.',
|
||||||
|
'array' => 'O campo :attribute não pode ter mais de :max itens.',
|
||||||
|
],
|
||||||
|
'mimes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.',
|
||||||
|
'mimetypes' => 'O campo :attribute deve ser um ficheiro do tipo: :values.',
|
||||||
|
'min' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser pelo menos :min.',
|
||||||
|
'file' => 'O campo :attribute deve ter pelo menos :min kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter pelo menos :min caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve ter pelo menos :min itens.',
|
||||||
|
],
|
||||||
|
'multiple_of' => 'O campo :attribute deve ser múltiplo de :value.',
|
||||||
|
'not_in' => 'O :attribute selecionado é inválido.',
|
||||||
|
'not_regex' => 'O formato do campo :attribute é inválido.',
|
||||||
|
'numeric' => 'O campo :attribute deve ser um número.',
|
||||||
|
'password' => 'A palavra-passe está incorreta.',
|
||||||
|
'present' => 'O campo :attribute deve estar presente.',
|
||||||
|
'prohibited' => 'O campo :attribute é proibido.',
|
||||||
|
'prohibited_if' => 'O campo :attribute é proibido quando :other é :value.',
|
||||||
|
'prohibited_unless' => 'O campo :attribute é proibido a menos que :other esteja em :values.',
|
||||||
|
'prohibits' => 'O campo :attribute proíbe :other de estar presente.',
|
||||||
|
'regex' => 'O formato do campo :attribute é inválido.',
|
||||||
|
'required' => 'Este campo é obrigatório.',
|
||||||
|
'required_array_keys' => 'O campo :attribute deve conter as chaves: :values.',
|
||||||
|
'required_if' => 'Este campo é obrigatório quando :other é :value.',
|
||||||
|
'required_unless' => 'Este campo é obrigatório a menos que :other esteja em :values.',
|
||||||
|
'required_with' => 'Este campo é obrigatório quando :values está presente.',
|
||||||
|
'required_with_all' => 'Este campo é obrigatório quando :values estão presentes.',
|
||||||
|
'required_without' => 'Este campo é obrigatório quando :values não está presente.',
|
||||||
|
'required_without_all' => 'Este campo é obrigatório quando nenhum de :values está presente.',
|
||||||
|
'same' => 'O campo :attribute e :other devem coincidir.',
|
||||||
|
'size' => [
|
||||||
|
'numeric' => 'O campo :attribute deve ser :size.',
|
||||||
|
'file' => 'O campo :attribute deve ter :size kilobytes.',
|
||||||
|
'string' => 'O campo :attribute deve ter :size caracteres.',
|
||||||
|
'array' => 'O campo :attribute deve conter :size itens.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'O campo :attribute deve começar com um dos seguintes: :values.',
|
||||||
|
'string' => 'O campo :attribute deve ser uma string.',
|
||||||
|
'timezone' => 'O campo :attribute deve ser um fuso horário válido.',
|
||||||
|
'unique' => 'O :attribute já está a ser utilizado.',
|
||||||
|
'uploaded' => 'O falha no upload do campo :attribute.',
|
||||||
|
'url' => 'O campo :attribute deve ser um URL válido.',
|
||||||
|
'uuid' => 'O campo :attribute deve ser um UUID válido.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'starts_at.after' => 'A data de início deve ser posterior à data atual.',
|
||||||
|
'starts_at.after_or_equal' => 'A data de início deve ser posterior ou igual à data atual.',
|
||||||
|
'ends_at.after' => 'A data de fim deve ser posterior à data de início.',
|
||||||
|
'ends_at.after_or_equal' => 'A data de fim deve ser posterior ou igual à data de início.',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [
|
||||||
|
'purpose' => 'finalidade',
|
||||||
|
'usage_location' => 'local de utilização',
|
||||||
|
'starts_at' => 'data de início',
|
||||||
|
'ends_at' => 'data de fim',
|
||||||
|
'asset_ids' => 'equipamentos',
|
||||||
|
'terms_accepted' => 'aceitação dos termos',
|
||||||
|
'email' => 'email',
|
||||||
|
'password' => 'palavra-passe',
|
||||||
|
],
|
||||||
|
];
|
||||||
690
resources/js/components/recurring-conflict-modal.tsx
Normal file
690
resources/js/components/recurring-conflict-modal.tsx
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { Asset, OccurrenceCheck } from '@/types';
|
||||||
|
|
||||||
|
type RecurringConflictModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
checkResult: {
|
||||||
|
occurrences: OccurrenceCheck[];
|
||||||
|
total_occurrences: number;
|
||||||
|
conflicts_count: number;
|
||||||
|
} | null;
|
||||||
|
initialAssetIds: number[];
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onConfirmSubmit: (
|
||||||
|
occurrencesToSubmit: Array<{
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
asset_ids: number[];
|
||||||
|
}>,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecurringConflictModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
isLoading,
|
||||||
|
checkResult,
|
||||||
|
initialAssetIds,
|
||||||
|
isSubmitting,
|
||||||
|
onConfirmSubmit,
|
||||||
|
}: RecurringConflictModalProps) {
|
||||||
|
const [omittedIndexes, setOmittedIndexes] = useState<number[]>([]);
|
||||||
|
// Key format: `${occurrenceIndex}_${originalAssetId}` -> replacement Asset
|
||||||
|
const [replacements, setReplacements] = useState<Record<string, Asset>>({});
|
||||||
|
const [activeSelectorKey, setActiveSelectorKey] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const occurrences = checkResult?.occurrences ?? [];
|
||||||
|
|
||||||
|
function toggleOmit(index: number) {
|
||||||
|
setOmittedIndexes((prev) =>
|
||||||
|
prev.includes(index)
|
||||||
|
? prev.filter((i) => i !== index)
|
||||||
|
: [...prev, index],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReplacementSelector(key: string) {
|
||||||
|
setActiveSelectorKey((prev) => (prev === key ? null : key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectReplacement(
|
||||||
|
occIndex: number,
|
||||||
|
originalAssetId: number,
|
||||||
|
replacement: Asset,
|
||||||
|
) {
|
||||||
|
setReplacements((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[`${occIndex}_${originalAssetId}`]: replacement,
|
||||||
|
}));
|
||||||
|
setActiveSelectorKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReplacement(occIndex: number, originalAssetId: number) {
|
||||||
|
setReplacements((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${occIndex}_${originalAssetId}`];
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeOccurrences = occurrences.filter(
|
||||||
|
(_, idx) => !omittedIndexes.includes(idx),
|
||||||
|
);
|
||||||
|
const omittedCount = occurrences.length - activeOccurrences.length;
|
||||||
|
|
||||||
|
// Check if there are unresolved conflicts in active occurrences
|
||||||
|
const hasUnresolvedConflicts = occurrences.some((check, idx) => {
|
||||||
|
if (omittedIndexes.includes(idx) || !check.has_conflict) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return check.conflicted_assets.some(
|
||||||
|
(ca) => !replacements[`${idx}_${ca.id}`],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!isLoading &&
|
||||||
|
activeOccurrences.length > 0 &&
|
||||||
|
!hasUnresolvedConflicts &&
|
||||||
|
!isSubmitting;
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
const payload = occurrences
|
||||||
|
.map((check, index) => ({ check, index }))
|
||||||
|
.filter(({ index }) => !omittedIndexes.includes(index))
|
||||||
|
.map(({ check, index }) => {
|
||||||
|
const finalAssetIds = initialAssetIds.map((origId) => {
|
||||||
|
const replacement = replacements[`${index}_${origId}`];
|
||||||
|
|
||||||
|
return replacement ? replacement.id : origId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
starts_at: check.starts_at,
|
||||||
|
ends_at: check.ends_at,
|
||||||
|
asset_ids: finalAssetIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onConfirmSubmit(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||||
|
<DialogHeader className="border-b p-6 pb-4">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl font-semibold">
|
||||||
|
<Calendar className="size-5 text-primary" />
|
||||||
|
Confirmação de Série Semanal
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Análise de disponibilidade de todas as semanas da sua
|
||||||
|
requisição recorrente.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-6 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4 py-16 text-center">
|
||||||
|
<RefreshCw className="mx-auto size-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
A verificar disponibilidade semana a semana…
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A cruzar horários com o inventário e requisições
|
||||||
|
existentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : checkResult ? (
|
||||||
|
<>
|
||||||
|
{/* Summary banner */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-card p-3 shadow-xs">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Semanas totais
|
||||||
|
</span>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{checkResult.total_occurrences}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-3 shadow-xs">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
A requisitar
|
||||||
|
</span>
|
||||||
|
<p className="text-2xl font-bold text-primary">
|
||||||
|
{activeOccurrences.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-3 shadow-xs">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Conflitos detetados
|
||||||
|
</span>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
checkResult.conflicts_count > 0
|
||||||
|
? 'text-amber-600 dark:text-amber-500'
|
||||||
|
: 'text-emerald-600 dark:text-emerald-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checkResult.conflicts_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informative alert */}
|
||||||
|
{checkResult.conflicts_count > 0 ? (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
Existem conflitos de horário com
|
||||||
|
outros pedidos!
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed opacity-90">
|
||||||
|
Para cada dia assinalado,
|
||||||
|
escolha se prefere{' '}
|
||||||
|
<strong>
|
||||||
|
apagar a reserva desse dia
|
||||||
|
</strong>{' '}
|
||||||
|
(excluindo-o da série) ou{' '}
|
||||||
|
<strong>
|
||||||
|
trocar o material
|
||||||
|
</strong>{' '}
|
||||||
|
por outro equipamento
|
||||||
|
disponível.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm text-emerald-900 dark:text-emerald-200">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||||
|
<p className="font-semibold">
|
||||||
|
Todos os equipamentos estão
|
||||||
|
disponíveis para todas as semanas da
|
||||||
|
série!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Occurrence Cards List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{occurrences.map((check, index) => {
|
||||||
|
const isOmitted =
|
||||||
|
omittedIndexes.includes(index);
|
||||||
|
const hasConflicts = check.has_conflict;
|
||||||
|
const allConflictsResolved =
|
||||||
|
hasConflicts &&
|
||||||
|
check.conflicted_assets.every(
|
||||||
|
(ca) =>
|
||||||
|
replacements[
|
||||||
|
`${index}_${ca.id}`
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={check.index}
|
||||||
|
className={`rounded-xl border p-4 transition-all ${
|
||||||
|
isOmitted
|
||||||
|
? 'border-dashed bg-slate-50 opacity-50 dark:bg-slate-900/50'
|
||||||
|
: hasConflicts &&
|
||||||
|
!allConflictsResolved
|
||||||
|
? 'border-amber-400 bg-amber-50/40 shadow-xs dark:border-amber-700/60 dark:bg-amber-950/20'
|
||||||
|
: 'border-slate-200 bg-card dark:border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
Semana {index + 1}
|
||||||
|
</span>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold ${
|
||||||
|
isOmitted
|
||||||
|
? 'text-muted-foreground line-through'
|
||||||
|
: 'text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
check.date_formatted
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{
|
||||||
|
check.time_formatted
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
{isOmitted ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-dashed text-muted-foreground"
|
||||||
|
>
|
||||||
|
Dia apagado /
|
||||||
|
ignorado
|
||||||
|
</Badge>
|
||||||
|
) : allConflictsResolved ? (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="bg-emerald-600 text-white"
|
||||||
|
>
|
||||||
|
Conflito
|
||||||
|
resolvido com
|
||||||
|
troca
|
||||||
|
</Badge>
|
||||||
|
) : hasConflicts ? (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-amber-600 text-white"
|
||||||
|
>
|
||||||
|
Conflito de
|
||||||
|
material
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-emerald-300 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
Equipamento
|
||||||
|
disponível
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{isOmitted ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
toggleOmit(
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 gap-1"
|
||||||
|
>
|
||||||
|
<Undo2 className="size-3.5" />
|
||||||
|
Restaurar este dia
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
toggleOmit(
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 gap-1 text-rose-600 hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-950/40"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Apagar este dia
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details of items & conflicts */}
|
||||||
|
{!isOmitted && (
|
||||||
|
<div className="mt-3 space-y-2 border-t pt-3 text-xs">
|
||||||
|
{check.conflicted_assets.map(
|
||||||
|
(cAsset) => {
|
||||||
|
const key = `${index}_${cAsset.id}`;
|
||||||
|
const replacement =
|
||||||
|
replacements[
|
||||||
|
key
|
||||||
|
];
|
||||||
|
const isSelecting =
|
||||||
|
activeSelectorKey ===
|
||||||
|
key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={
|
||||||
|
cAsset.id
|
||||||
|
}
|
||||||
|
className="space-y-2 rounded-lg border border-amber-300/80 bg-amber-50/60 p-3 dark:border-amber-800/80 dark:bg-amber-950/30"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-amber-900 dark:text-amber-200">
|
||||||
|
Material
|
||||||
|
em
|
||||||
|
conflito:{' '}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{
|
||||||
|
cAsset.name
|
||||||
|
}{' '}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
cAsset.asset_tag
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Já
|
||||||
|
se
|
||||||
|
encontra
|
||||||
|
reservado
|
||||||
|
por
|
||||||
|
outro
|
||||||
|
utilizador
|
||||||
|
para
|
||||||
|
este
|
||||||
|
horário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{replacement ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
removeReplacement(
|
||||||
|
index,
|
||||||
|
cAsset.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Undo2 className="mr-1 size-3" />{' '}
|
||||||
|
Reverter
|
||||||
|
troca
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
toggleReplacementSelector(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
{isSelecting
|
||||||
|
? 'Fechar lista'
|
||||||
|
: 'Trocar material'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Replacement indicator */}
|
||||||
|
{replacement && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-emerald-400 bg-emerald-50 p-2 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||||
|
<span>
|
||||||
|
Substituído
|
||||||
|
por:{' '}
|
||||||
|
<strong>
|
||||||
|
{
|
||||||
|
replacement.name
|
||||||
|
}
|
||||||
|
</strong>{' '}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
replacement.asset_tag
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interactive Alternatives Selector */}
|
||||||
|
{isSelecting &&
|
||||||
|
!replacement && (
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-foreground">
|
||||||
|
Equipamentos
|
||||||
|
disponíveis
|
||||||
|
para
|
||||||
|
este
|
||||||
|
dia
|
||||||
|
(
|
||||||
|
{
|
||||||
|
cAsset
|
||||||
|
.alternatives
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
):
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
toggleReplacementSelector(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cAsset
|
||||||
|
.alternatives
|
||||||
|
.length ===
|
||||||
|
0 ? (
|
||||||
|
<p className="py-1 text-muted-foreground italic">
|
||||||
|
Não
|
||||||
|
foram
|
||||||
|
encontrados
|
||||||
|
equipamentos
|
||||||
|
alternativos
|
||||||
|
livres
|
||||||
|
para
|
||||||
|
este
|
||||||
|
horário.
|
||||||
|
Sugerimos
|
||||||
|
apagar
|
||||||
|
a
|
||||||
|
reserva
|
||||||
|
deste
|
||||||
|
dia.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid max-h-40 gap-1.5 overflow-y-auto">
|
||||||
|
{cAsset.alternatives.map(
|
||||||
|
(
|
||||||
|
alt,
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
key={
|
||||||
|
alt.id
|
||||||
|
}
|
||||||
|
className="flex cursor-pointer items-center justify-between rounded border p-2 transition-colors hover:bg-muted/50"
|
||||||
|
onClick={() =>
|
||||||
|
selectReplacement(
|
||||||
|
index,
|
||||||
|
cAsset.id,
|
||||||
|
alt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{
|
||||||
|
alt.name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 font-mono text-[10px] text-muted-foreground">
|
||||||
|
{
|
||||||
|
alt.asset_tag
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{alt.category_name && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||||
|
·{' '}
|
||||||
|
{
|
||||||
|
alt.category_name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={(
|
||||||
|
e,
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectReplacement(
|
||||||
|
index,
|
||||||
|
cAsset.id,
|
||||||
|
alt,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Escolher
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available assets for this occurrence */}
|
||||||
|
{check.available_assets
|
||||||
|
.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Package className="size-3.5 shrink-0 text-emerald-600" />
|
||||||
|
<span>
|
||||||
|
Disponíveis:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{check.available_assets
|
||||||
|
.map(
|
||||||
|
(a) =>
|
||||||
|
`${a.name} (${a.asset_tag})`,
|
||||||
|
)
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col justify-between gap-3 border-t bg-slate-50 p-4 sm:flex-row sm:items-center dark:bg-slate-900/60">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{activeOccurrences.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
A submeter{' '}
|
||||||
|
<strong>{activeOccurrences.length}</strong>{' '}
|
||||||
|
requisições
|
||||||
|
{omittedCount > 0
|
||||||
|
? ` (${omittedCount} dias ignorados)`
|
||||||
|
: ''}
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-rose-600">
|
||||||
|
Todos os dias foram apagados. Restaure pelo
|
||||||
|
menos um dia para submeter.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasUnresolvedConflicts && (
|
||||||
|
<span className="mt-0.5 block font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
⚠ Resolva os conflitos pendentes (trocando o
|
||||||
|
material ou apagando o dia).
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-4 animate-spin" />
|
||||||
|
A submeter…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Confirmar e submeter série
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
resources/js/components/weekly-slot-selector.tsx
Normal file
216
resources/js/components/weekly-slot-selector.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import type { WeeklySlot } from '@/types';
|
||||||
|
|
||||||
|
interface WeeklySlotSelectorProps {
|
||||||
|
weeklySlots: WeeklySlot[];
|
||||||
|
onChange: (slots: WeeklySlot[]) => void;
|
||||||
|
slotAvailability?: Map<string, boolean>;
|
||||||
|
weekOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSlots = [
|
||||||
|
{ start: '08:30', end: '09:30' },
|
||||||
|
{ start: '09:40', end: '10:40' },
|
||||||
|
{ start: '10:50', end: '11:50' },
|
||||||
|
{ start: '11:50', end: '12:50' },
|
||||||
|
{ start: '13:00', end: '14:00' },
|
||||||
|
{ start: '14:00', end: '15:00' },
|
||||||
|
{ start: '15:10', end: '16:10' },
|
||||||
|
{ start: '16:20', end: '17:20' },
|
||||||
|
{ start: '17:20', end: '18:20' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const weekDays = [
|
||||||
|
{ value: 1, label: 'Seg' },
|
||||||
|
{ value: 2, label: 'Ter' },
|
||||||
|
{ value: 3, label: 'Qua' },
|
||||||
|
{ value: 4, label: 'Qui' },
|
||||||
|
{ value: 5, label: 'Sex' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WeeklySlotSelector({
|
||||||
|
weeklySlots,
|
||||||
|
onChange,
|
||||||
|
slotAvailability,
|
||||||
|
weekOffset = 0,
|
||||||
|
}: WeeklySlotSelectorProps) {
|
||||||
|
// Calculate dates for the week
|
||||||
|
const getWeekDates = (offset: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // Monday
|
||||||
|
|
||||||
|
const dates: Record<number, Date> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const date = new Date(startOfWeek);
|
||||||
|
date.setDate(startOfWeek.getDate() + i);
|
||||||
|
dates[i + 1] = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekDates = getWeekDates(weekOffset);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('pt-PT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSlotSelected = (day: number, start: string, end: string) => {
|
||||||
|
return weeklySlots.some(
|
||||||
|
(slot) =>
|
||||||
|
slot.day_of_week === day &&
|
||||||
|
slot.start_time === start &&
|
||||||
|
slot.end_time === end,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSlotAvailable = (day: number, start: string, end: string) => {
|
||||||
|
if (!slotAvailability) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${day}-${start}-${end}`;
|
||||||
|
|
||||||
|
return slotAvailability.get(key) ?? true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSlot = (day: number, start: string, end: string) => {
|
||||||
|
if (!isSlotAvailable(day, start, end)) {
|
||||||
|
return; // Don't allow selecting unavailable slots
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = weeklySlots.findIndex(
|
||||||
|
(slot) =>
|
||||||
|
slot.day_of_week === day &&
|
||||||
|
slot.start_time === start &&
|
||||||
|
slot.end_time === end,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Remove slot
|
||||||
|
const newSlots = weeklySlots.filter((_, i) => i !== existingIndex);
|
||||||
|
onChange(newSlots);
|
||||||
|
} else {
|
||||||
|
// Add slot
|
||||||
|
onChange([
|
||||||
|
...weeklySlots,
|
||||||
|
{ day_of_week: day, start_time: start, end_time: end },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Selecione os horários</Label>
|
||||||
|
{weeklySlots.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAll}
|
||||||
|
>
|
||||||
|
Limpar tudo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[500px]">
|
||||||
|
{/* Header row with day names */}
|
||||||
|
<div className="grid grid-cols-6 gap-2 mb-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground" />
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.value}
|
||||||
|
className="text-center text-xs font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{day.label} <span className="text-muted-foreground">{formatDate(weekDates[day.value])}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time slots rows */}
|
||||||
|
{timeSlots.map((time) => (
|
||||||
|
<div key={time.start} className="grid grid-cols-6 gap-2 mb-2">
|
||||||
|
<div className="flex items-center text-xs text-muted-foreground">
|
||||||
|
{time.start}-{time.end}
|
||||||
|
</div>
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const selected = isSlotSelected(
|
||||||
|
day.value,
|
||||||
|
time.start,
|
||||||
|
time.end,
|
||||||
|
);
|
||||||
|
const available = isSlotAvailable(
|
||||||
|
day.value,
|
||||||
|
time.start,
|
||||||
|
time.end,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${day.value}-${time.start}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
toggleSlot(
|
||||||
|
day.value,
|
||||||
|
time.start,
|
||||||
|
time.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!available}
|
||||||
|
title={
|
||||||
|
available
|
||||||
|
? `${time.start} - ${time.end}`
|
||||||
|
: 'Sem equipamentos disponíveis'
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
relative rounded-lg border-2 p-2 text-center text-xs transition-all
|
||||||
|
${
|
||||||
|
selected
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: available
|
||||||
|
? 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
||||||
|
: 'border-slate-200 bg-slate-100 opacity-50 cursor-not-allowed dark:border-slate-700 dark:bg-slate-800/30'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{time.start}-{time.end}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="absolute right-1 top-1">
|
||||||
|
<Check className="size-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!available && (
|
||||||
|
<div className="absolute -top-1 -right-1 size-3 rounded-full bg-destructive" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weeklySlots.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-300 p-4 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||||
|
Selecione pelo menos um horário para continuar
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ import { Head, Link, router } from '@inertiajs/react';
|
|||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
FilterX,
|
||||||
MapPin,
|
MapPin,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Search,
|
Search,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -67,6 +70,22 @@ export default function AssetsIndex({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setSearch('');
|
||||||
|
setCategory('all');
|
||||||
|
setLocation('all');
|
||||||
|
setStartsAt('');
|
||||||
|
setEndsAt('');
|
||||||
|
router.get('/equipamentos', {}, { preserveState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
search ||
|
||||||
|
category !== 'all' ||
|
||||||
|
location !== 'all' ||
|
||||||
|
startsAt ||
|
||||||
|
endsAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Equipamentos" />
|
<Head title="Equipamentos" />
|
||||||
@@ -77,81 +96,121 @@ export default function AssetsIndex({
|
|||||||
description="Consulte os equipamentos requisitáveis registados no Snipe-IT e inicie uma requisição."
|
description="Consulte os equipamentos requisitáveis registados no Snipe-IT e inicie uma requisição."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form
|
<Card className="border-slate-200 shadow-none dark:border-slate-800">
|
||||||
onSubmit={submit}
|
<CardContent className="p-4">
|
||||||
className="grid gap-3 rounded-xl border bg-card p-4 lg:grid-cols-6"
|
<form onSubmit={submit} className="space-y-4">
|
||||||
>
|
<div className="relative">
|
||||||
<div className="relative lg:col-span-2">
|
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
<Input
|
||||||
<Input
|
value={search}
|
||||||
value={search}
|
onChange={(event) =>
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
setSearch(event.target.value)
|
||||||
placeholder="Nome, modelo ou etiqueta…"
|
}
|
||||||
className="pl-9"
|
placeholder="Pesquisar equipamentos por nome, modelo ou etiqueta…"
|
||||||
/>
|
className="h-12 pl-10 text-base"
|
||||||
</div>
|
/>
|
||||||
<Select value={category} onValueChange={setCategory}>
|
</div>
|
||||||
<SelectTrigger>
|
<details className="group">
|
||||||
<SelectValue placeholder="Categoria" />
|
<summary className="flex cursor-pointer items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
|
||||||
</SelectTrigger>
|
<SlidersHorizontal className="size-4" />
|
||||||
<SelectContent>
|
Mais filtros
|
||||||
<SelectItem value="all">
|
</summary>
|
||||||
Todas as categorias
|
<div className="mt-4 grid gap-4 lg:grid-cols-5">
|
||||||
</SelectItem>
|
<Select
|
||||||
{categories.map((item) => (
|
value={category}
|
||||||
<SelectItem key={item} value={item}>
|
onValueChange={setCategory}
|
||||||
{item}
|
>
|
||||||
</SelectItem>
|
<SelectTrigger>
|
||||||
))}
|
<SelectValue placeholder="Categoria" />
|
||||||
</SelectContent>
|
</SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
<div className="relative">
|
<SelectItem value="all">
|
||||||
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
Todas as categorias
|
||||||
<Input
|
</SelectItem>
|
||||||
type="datetime-local"
|
{categories.map((item) => (
|
||||||
aria-label="Início do período"
|
<SelectItem
|
||||||
value={startsAt}
|
key={item}
|
||||||
onChange={(event) =>
|
value={item}
|
||||||
setStartsAt(event.target.value)
|
>
|
||||||
}
|
{item}
|
||||||
className="pl-9"
|
</SelectItem>
|
||||||
required={Boolean(endsAt)}
|
))}
|
||||||
/>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
<Input
|
<div className="relative">
|
||||||
type="datetime-local"
|
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||||
aria-label="Fim do período"
|
<Input
|
||||||
value={endsAt}
|
type="datetime-local"
|
||||||
onChange={(event) => setEndsAt(event.target.value)}
|
aria-label="Início do período"
|
||||||
required={Boolean(startsAt)}
|
value={startsAt}
|
||||||
/>
|
onChange={(event) =>
|
||||||
<Select value={location} onValueChange={setLocation}>
|
setStartsAt(event.target.value)
|
||||||
<SelectTrigger>
|
}
|
||||||
<SelectValue placeholder="Localização" />
|
className="pl-9"
|
||||||
</SelectTrigger>
|
required={Boolean(endsAt)}
|
||||||
<SelectContent>
|
/>
|
||||||
<SelectItem value="all">
|
</div>
|
||||||
Todas as localizações
|
<Input
|
||||||
</SelectItem>
|
type="datetime-local"
|
||||||
{locations.map((item) => (
|
aria-label="Fim do período"
|
||||||
<SelectItem key={item} value={item}>
|
value={endsAt}
|
||||||
{item}
|
onChange={(event) =>
|
||||||
</SelectItem>
|
setEndsAt(event.target.value)
|
||||||
))}
|
}
|
||||||
</SelectContent>
|
required={Boolean(startsAt)}
|
||||||
</Select>
|
/>
|
||||||
<Button type="submit" variant="secondary">
|
<Select
|
||||||
<SlidersHorizontal /> Filtrar
|
value={location}
|
||||||
</Button>
|
onValueChange={setLocation}
|
||||||
</form>
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Localização" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
Todas as localizações
|
||||||
|
</SelectItem>
|
||||||
|
{locations.map((item) => (
|
||||||
|
<SelectItem
|
||||||
|
key={item}
|
||||||
|
value={item}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal /> Filtrar
|
||||||
|
</Button>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
<FilterX />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<span>
|
<span className="font-medium">
|
||||||
{assets.total} equipamento
|
{assets.total} equipamento
|
||||||
{assets.total === 1 ? '' : 's'}
|
{assets.total === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
{lastSyncedAt && (
|
{lastSyncedAt && (
|
||||||
<span>
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-500" />
|
||||||
Sincronizado em{' '}
|
Sincronizado em{' '}
|
||||||
{new Intl.DateTimeFormat('pt-PT', {
|
{new Intl.DateTimeFormat('pt-PT', {
|
||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
@@ -182,36 +241,82 @@ export default function AssetsIndex({
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Altere os filtros e volte a tentar.
|
Altere os filtros e volte a tentar.
|
||||||
</p>
|
</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Limpar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{assets.last_page > 1 && (
|
{assets.last_page > 1 && (
|
||||||
<nav className="flex justify-center gap-2">
|
<nav className="flex justify-center gap-2">
|
||||||
{assets.links.map((link, index) => (
|
{assets.links.map((link, index) => {
|
||||||
<Button
|
const isFirst = index === 0;
|
||||||
key={index}
|
const isLast = index === assets.links.length - 1;
|
||||||
asChild={Boolean(link.url)}
|
|
||||||
disabled={!link.url}
|
if (isFirst || isLast) {
|
||||||
variant={link.active ? 'default' : 'outline'}
|
const icon = isFirst ? (
|
||||||
size="sm"
|
<ChevronLeft className="size-4" />
|
||||||
>
|
|
||||||
{link.url ? (
|
|
||||||
<Link
|
|
||||||
href={link.url}
|
|
||||||
preserveScroll
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: link.label,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<span
|
<ChevronRight className="size-4" />
|
||||||
dangerouslySetInnerHTML={{
|
);
|
||||||
__html: link.label,
|
|
||||||
}}
|
return (
|
||||||
/>
|
<Button
|
||||||
)}
|
key={index}
|
||||||
</Button>
|
asChild={Boolean(link.url)}
|
||||||
))}
|
disabled={!link.url}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-w-[32px]"
|
||||||
|
>
|
||||||
|
{link.url ? (
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
preserveScroll
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>{icon}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
asChild={Boolean(link.url)}
|
||||||
|
disabled={!link.url}
|
||||||
|
variant={
|
||||||
|
link.active ? 'default' : 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{link.url ? (
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
preserveScroll
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: link.label,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: link.label,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -238,35 +343,44 @@ function AssetCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md dark:border-slate-800">
|
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition-all hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg dark:border-slate-800">
|
||||||
<div className="flex h-36 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
|
<div className="relative flex h-40 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
|
||||||
{asset.image_url ? (
|
{asset.image_url ? (
|
||||||
<img
|
<img
|
||||||
src={asset.image_url}
|
src={asset.image_url}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PackageCheck className="size-12 text-slate-300 transition group-hover:text-primary/40" />
|
<PackageCheck className="size-14 text-slate-300 transition group-hover:text-primary/50" />
|
||||||
)}
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="absolute top-3 right-3 bg-white/90 backdrop-blur-sm dark:bg-slate-900/90"
|
||||||
|
>
|
||||||
|
{periodSelected ? 'Livre no período' : 'Requisitável'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="space-y-4 pb-5">
|
<CardContent className="space-y-4 pb-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="font-semibold">{asset.name}</h2>
|
<h2 className="truncate font-semibold">{asset.name}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
{asset.model_name}
|
{asset.model_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-emerald-700">
|
|
||||||
{periodSelected ? 'Livre no período' : 'Requisitável'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
|
||||||
<span className="font-mono">{asset.asset_tag}</span>
|
<span className="rounded bg-muted px-2 py-0.5 font-mono">
|
||||||
{asset.category_name && <span>{asset.category_name}</span>}
|
{asset.asset_tag}
|
||||||
|
</span>
|
||||||
|
{asset.category_name && (
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5">
|
||||||
|
{asset.category_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{asset.location_name && (
|
{asset.location_name && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 rounded bg-muted px-2 py-0.5">
|
||||||
<MapPin className="size-3" />
|
<MapPin className="size-3" />
|
||||||
{asset.location_name}
|
{asset.location_name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
|
import { Laptop2, ShieldCheck } from 'lucide-react';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import PasskeyVerify from '@/components/passkey-verify';
|
import PasskeyVerify from '@/components/passkey-verify';
|
||||||
import PasswordInput from '@/components/password-input';
|
import PasswordInput from '@/components/password-input';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -26,6 +28,18 @@ export default function Login({
|
|||||||
<>
|
<>
|
||||||
<Head title="Entrar" />
|
<Head title="Entrar" />
|
||||||
|
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 dark:from-primary/10 dark:to-primary/5">
|
||||||
|
<Laptop2 className="size-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
Portal de Reservas
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Sistema de gestão de equipamentos da escola
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PasskeyVerify
|
<PasskeyVerify
|
||||||
label="Entrar com chave de acesso"
|
label="Entrar com chave de acesso"
|
||||||
loadingLabel="A autenticar..."
|
loadingLabel="A autenticar..."
|
||||||
@@ -55,7 +69,7 @@ export default function Login({
|
|||||||
>
|
>
|
||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-5">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -67,6 +81,7 @@ export default function Login({
|
|||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="nome@escola.pt"
|
placeholder="nome@escola.pt"
|
||||||
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.email} />
|
<InputError message={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +108,7 @@ export default function Login({
|
|||||||
tabIndex={2}
|
tabIndex={2}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Palavra-passe"
|
placeholder="Palavra-passe"
|
||||||
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.password} />
|
<InputError message={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
@@ -103,12 +119,14 @@ export default function Login({
|
|||||||
name="remember"
|
name="remember"
|
||||||
tabIndex={3}
|
tabIndex={3}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="remember">Manter sessão</Label>
|
<Label htmlFor="remember" className="text-sm">
|
||||||
|
Manter sessão
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-4 w-full"
|
className="mt-2 h-11 w-full"
|
||||||
tabIndex={4}
|
tabIndex={4}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
data-test="login-button"
|
data-test="login-button"
|
||||||
@@ -118,10 +136,21 @@ export default function Login({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<Card className="border-primary/20 bg-primary/5 dark:border-primary/10 dark:bg-primary/5">
|
||||||
O acesso é atribuído pela escola. Contacte os
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
Serviços de Informática se ainda não tiver conta.
|
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||||
</p>
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Acesso seguro
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
O acesso é atribuído pela escola.
|
||||||
|
Contacte os Serviços de Informática se
|
||||||
|
ainda não tiver conta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Clock3,
|
Clock3,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Plus,
|
Plus,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeading } from '@/components/page-heading';
|
import { PageHeading } from '@/components/page-heading';
|
||||||
import { ReservationStatus } from '@/components/reservation-status';
|
import { ReservationStatus } from '@/components/reservation-status';
|
||||||
@@ -206,23 +207,42 @@ function SummaryCard({
|
|||||||
tone: 'amber' | 'emerald' | 'blue';
|
tone: 'amber' | 'emerald' | 'blue';
|
||||||
}) {
|
}) {
|
||||||
const colors = {
|
const colors = {
|
||||||
amber: 'bg-amber-50 text-amber-700',
|
amber: {
|
||||||
emerald: 'bg-emerald-50 text-emerald-700',
|
bg: 'bg-amber-50 dark:bg-amber-950/30',
|
||||||
blue: 'bg-blue-50 text-blue-700',
|
text: 'text-amber-700 dark:text-amber-400',
|
||||||
|
icon: 'bg-amber-100 dark:bg-amber-900/50',
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
|
||||||
|
text: 'text-emerald-700 dark:text-emerald-400',
|
||||||
|
icon: 'bg-emerald-100 dark:bg-emerald-900/50',
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||||
|
text: 'text-blue-700 dark:text-blue-400',
|
||||||
|
icon: 'bg-blue-100 dark:bg-blue-900/50',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="gap-3 border-slate-200 py-5 shadow-none dark:border-slate-800">
|
<Card className="group overflow-hidden border-slate-200 py-0 shadow-none transition-all hover:shadow-md dark:border-slate-800">
|
||||||
<CardContent className="flex items-center gap-4">
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
<span
|
<span
|
||||||
className={`flex size-11 items-center justify-center rounded-xl ${colors[tone]}`}
|
className={`flex size-12 items-center justify-center rounded-2xl ${colors[tone].icon} transition-transform group-hover:scale-110`}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" />
|
<Icon className={`size-6 ${colors[tone].text}`} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-2xl font-semibold">{value}</p>
|
<p className="text-3xl font-bold tracking-tight">{value}</p>
|
||||||
<p className="text-sm text-muted-foreground">{label}</p>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{value > 0 && (
|
||||||
|
<TrendingUp
|
||||||
|
className={`size-4 ${colors[tone].text} opacity-50`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import { ArrowLeft, ArrowRight, CalendarDays, Plus } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, CalendarDays, Clock, Filter, Package, Plus, User, X } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { PageHeading } from '@/components/page-heading';
|
import { PageHeading } from '@/components/page-heading';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Reservation } from '@/types';
|
import type { Reservation } from '@/types';
|
||||||
|
|
||||||
const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
|
const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: 'Todos os estados' },
|
||||||
|
{ value: 'pending', label: 'Pendente' },
|
||||||
|
{ value: 'confirmed', label: 'Confirmada ou em curso' },
|
||||||
|
{ value: 'closed', label: 'Fechada sem utilização' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ReservationCalendar({
|
export default function ReservationCalendar({
|
||||||
month,
|
month,
|
||||||
reservations,
|
reservations,
|
||||||
scope = 'personal',
|
scope = 'personal',
|
||||||
|
status = '',
|
||||||
}: {
|
}: {
|
||||||
month: string;
|
month: string;
|
||||||
reservations: (Reservation & {
|
reservations: (Reservation & {
|
||||||
requester?: { name: string; department: string | null };
|
requester?: { name: string; department: string | null };
|
||||||
})[];
|
})[];
|
||||||
scope?: 'personal' | 'global';
|
scope?: 'personal' | 'global';
|
||||||
|
status?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||||
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);
|
||||||
const firstWeekday = (monthStart.getDay() + 6) % 7;
|
const firstWeekday = (monthStart.getDay() + 6) % 7;
|
||||||
@@ -41,6 +66,59 @@ export default function ReservationCalendar({
|
|||||||
? '/calendario-global'
|
? '/calendario-global'
|
||||||
: '/requisicoes/calendario';
|
: '/requisicoes/calendario';
|
||||||
|
|
||||||
|
function handleStatusChange(value: string) {
|
||||||
|
router.get(calendarUrl, { month, status: value || undefined }, {
|
||||||
|
preserveState: true,
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(day: Date) {
|
||||||
|
setSelectedDay(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('pt-PT', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('pt-PT', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string): string {
|
||||||
|
if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) {
|
||||||
|
return 'Fechada sem utilização';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) {
|
||||||
|
return 'Confirmada ou em curso';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Pendente';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) {
|
||||||
|
return 'bg-slate-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) {
|
||||||
|
return 'bg-emerald-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-amber-500';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title={global ? 'Calendário global' : 'O meu calendário'} />
|
<Head title={global ? 'Calendário global' : 'O meu calendário'} />
|
||||||
@@ -67,6 +145,32 @@ export default function ReservationCalendar({
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="size-4 text-muted-foreground" />
|
||||||
|
<Select value={status} onValueChange={handleStatusChange}>
|
||||||
|
<SelectTrigger className="w-64">
|
||||||
|
<SelectValue placeholder="Filtrar por estado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{status && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatusChange('')}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="overflow-hidden rounded-xl border bg-card">
|
<section className="overflow-hidden rounded-xl border bg-card">
|
||||||
<header className="flex items-center justify-between border-b p-4">
|
<header className="flex items-center justify-between border-b p-4">
|
||||||
@@ -118,10 +222,12 @@ export default function ReservationCalendar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.toISOString()}
|
key={day.toISOString()}
|
||||||
|
onClick={() => entries.length > 0 && handleDayClick(day)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2',
|
'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2 cursor-pointer',
|
||||||
!isCurrentMonth &&
|
!isCurrentMonth &&
|
||||||
'bg-slate-50/70 text-muted-foreground dark:bg-slate-950/40',
|
'bg-slate-50/70 text-muted-foreground dark:bg-slate-950/40',
|
||||||
|
entries.length > 0 && 'hover:bg-slate-50 dark:hover:bg-slate-900/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -184,6 +290,92 @@ export default function ReservationCalendar({
|
|||||||
requisições neste mês.
|
requisições neste mês.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={selectedDay !== null} onOpenChange={() => setSelectedDay(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Requisições do dia</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedDay && (() => {
|
||||||
|
const dayStart = new Date(
|
||||||
|
selectedDay.getFullYear(),
|
||||||
|
selectedDay.getMonth(),
|
||||||
|
selectedDay.getDate(),
|
||||||
|
);
|
||||||
|
const dayEnd = new Date(
|
||||||
|
selectedDay.getFullYear(),
|
||||||
|
selectedDay.getMonth(),
|
||||||
|
selectedDay.getDate() + 1,
|
||||||
|
);
|
||||||
|
const dayEntries = reservations.filter(
|
||||||
|
(reservation) =>
|
||||||
|
new Date(reservation.starts_at) < dayEnd &&
|
||||||
|
new Date(reservation.ends_at) > dayStart,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(selectedDay)}
|
||||||
|
</p>
|
||||||
|
{dayEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Não existem requisições neste dia.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dayEntries.map((reservation) => (
|
||||||
|
<div
|
||||||
|
key={reservation.id}
|
||||||
|
className="rounded-lg border bg-slate-50 p-4 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-sm">
|
||||||
|
{reservation.reference}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium text-white',
|
||||||
|
getStatusColor(reservation.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getStatusLabel(reservation.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{global && reservation.requester && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<User className="size-3" />
|
||||||
|
<span>{reservation.requester.name}</span>
|
||||||
|
{reservation.requester.department && (
|
||||||
|
<span>· {reservation.requester.department}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
<span>
|
||||||
|
{formatTime(reservation.starts_at)} - {formatTime(reservation.ends_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Package className="size-3 mt-0.5" />
|
||||||
|
<span className="line-clamp-2">
|
||||||
|
{reservation.items.map((item) => item.asset.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,18 @@
|
|||||||
import { Head, Link, useForm } from '@inertiajs/react';
|
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||||
import { Ban, CalendarDays, MessageSquareText, Plus } from 'lucide-react';
|
import {
|
||||||
|
Ban,
|
||||||
|
CalendarDays,
|
||||||
|
MessageSquareText,
|
||||||
|
Plus,
|
||||||
|
Repeat,
|
||||||
|
Filter,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeading } from '@/components/page-heading';
|
import { PageHeading } from '@/components/page-heading';
|
||||||
import { ReservationStatus } from '@/components/reservation-status';
|
import { ReservationStatus } from '@/components/reservation-status';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,12 +23,39 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import type { Paginated, Reservation } from '@/types';
|
import type { Paginated, Reservation } from '@/types';
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: 'Todos os estados' },
|
||||||
|
{ value: 'pending_approval', label: 'Pendente de aprovação' },
|
||||||
|
{ value: 'awaiting_information', label: 'A aguardar esclarecimentos' },
|
||||||
|
{ value: 'approved', label: 'Aprovada' },
|
||||||
|
{ value: 'ready', label: 'Pronta para entrega' },
|
||||||
|
{ value: 'delivered', label: 'Entregue' },
|
||||||
|
{ value: 'in_use', label: 'Em utilização' },
|
||||||
|
{ value: 'returned', label: 'Devolvida' },
|
||||||
|
{ value: 'completed', label: 'Concluída' },
|
||||||
|
{ value: 'rejected', label: 'Rejeitada' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelada' },
|
||||||
|
{ value: 'expired', label: 'Expirada' },
|
||||||
|
{ value: 'no_show', label: 'Falta' },
|
||||||
|
{ value: 'overdue', label: 'Atrasada' },
|
||||||
|
{ value: 'incident', label: 'Incidente' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ReservationsIndex({
|
export default function ReservationsIndex({
|
||||||
reservations,
|
reservations,
|
||||||
|
status = '',
|
||||||
}: {
|
}: {
|
||||||
reservations: Paginated<Reservation>;
|
reservations: Paginated<Reservation>;
|
||||||
|
status?: string;
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState<Reservation | null>(null);
|
const [selected, setSelected] = useState<Reservation | null>(null);
|
||||||
const [clarification, setClarification] = useState<Reservation | null>(
|
const [clarification, setClarification] = useState<Reservation | null>(
|
||||||
@@ -39,6 +75,13 @@ export default function ReservationsIndex({
|
|||||||
'ready',
|
'ready',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function handleStatusChange(value: string) {
|
||||||
|
router.get('/requisicoes', { status: value || undefined }, {
|
||||||
|
preserveState: true,
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openCancellation(reservation: Reservation) {
|
function openCancellation(reservation: Reservation) {
|
||||||
cancelForm.reset();
|
cancelForm.reset();
|
||||||
cancelForm.clearErrors();
|
cancelForm.clearErrors();
|
||||||
@@ -93,6 +136,32 @@ export default function ReservationsIndex({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="size-4 text-muted-foreground" />
|
||||||
|
<Select value={status} onValueChange={handleStatusChange}>
|
||||||
|
<SelectTrigger className="w-64">
|
||||||
|
<SelectValue placeholder="Filtrar por estado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{status && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatusChange('')}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{reservations.data.length ? (
|
{reservations.data.length ? (
|
||||||
<div className="overflow-hidden rounded-xl border bg-card">
|
<div className="overflow-hidden rounded-xl border bg-card">
|
||||||
<div className="hidden grid-cols-[150px_1fr_220px_150px_110px] gap-4 border-b bg-slate-50 px-5 py-3 text-xs font-medium text-muted-foreground uppercase md:grid dark:bg-slate-900">
|
<div className="hidden grid-cols-[150px_1fr_220px_150px_110px] gap-4 border-b bg-slate-50 px-5 py-3 text-xs font-medium text-muted-foreground uppercase md:grid dark:bg-slate-900">
|
||||||
@@ -107,9 +176,20 @@ export default function ReservationsIndex({
|
|||||||
key={reservation.id}
|
key={reservation.id}
|
||||||
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_150px_110px] md:items-center"
|
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_150px_110px] md:items-center"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-sm font-medium">
|
<div className="flex flex-col gap-1">
|
||||||
{reservation.reference}
|
<span className="font-mono text-sm font-medium">
|
||||||
</span>
|
{reservation.reference}
|
||||||
|
</span>
|
||||||
|
{reservation.recurring_group_id && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit gap-1 border-primary/30 text-[10px] text-primary"
|
||||||
|
>
|
||||||
|
<Repeat className="size-2.5" />{' '}
|
||||||
|
Série semanal
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
{reservation.status ===
|
{reservation.status ===
|
||||||
'awaiting_information' && (
|
'awaiting_information' && (
|
||||||
|
|||||||
@@ -29,9 +29,31 @@ export type Reservation = {
|
|||||||
ends_at: string;
|
ends_at: string;
|
||||||
status: string;
|
status: string;
|
||||||
decision_reason?: string | null;
|
decision_reason?: string | null;
|
||||||
|
recurring_group_id?: string | null;
|
||||||
|
recurrence_pattern?: string | null;
|
||||||
items: ReservationItem[];
|
items: ReservationItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConflictedAsset = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
asset_tag: string;
|
||||||
|
model_name: string | null;
|
||||||
|
category_name: string | null;
|
||||||
|
alternatives: Asset[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OccurrenceCheck = {
|
||||||
|
index: number;
|
||||||
|
date_formatted: string;
|
||||||
|
time_formatted: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
has_conflict: boolean;
|
||||||
|
conflicted_assets: ConflictedAsset[];
|
||||||
|
available_assets: Asset[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PaginationLink = {
|
export type PaginationLink = {
|
||||||
url: string | null;
|
url: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -47,3 +69,9 @@ export type Paginated<T> = {
|
|||||||
total: number;
|
total: number;
|
||||||
links: PaginationLink[];
|
links: PaginationLink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WeeklySlot = {
|
||||||
|
day_of_week: number; // 0-6 (Sunday-Saturday)
|
||||||
|
start_time: string; // HH:mm format
|
||||||
|
end_time: string; // HH:mm format
|
||||||
|
};
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::get('requisicoes/calendario', [ReservationController::class, 'calendar'])->name('reservations.calendar');
|
Route::get('requisicoes/calendario', [ReservationController::class, 'calendar'])->name('reservations.calendar');
|
||||||
Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create');
|
Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create');
|
||||||
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
|
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
|
||||||
|
Route::post('requisicoes/verificar-recorrencia', [ReservationController::class, 'checkRecurrence'])->name('reservations.check-recurrence');
|
||||||
|
Route::post('requisicoes/verificar-disponibilidade-semanal', [ReservationController::class, 'checkWeeklyAvailability'])->name('reservations.check-weekly-availability');
|
||||||
|
Route::post('requisicoes/verificar-disponibilidade-slots', [ReservationController::class, 'checkSlotAvailability'])->name('reservations.check-slot-availability');
|
||||||
|
Route::post('requisicoes/multiplas', [ReservationController::class, 'storeMultiple'])->name('reservations.store-multiple');
|
||||||
|
Route::post('requisicoes/recorrente', [ReservationController::class, 'storeRecurring'])->name('reservations.store-recurring');
|
||||||
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
|
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
|
||||||
Route::post('requisicoes/{reservation:uuid}/responder', [ReservationController::class, 'respondToInformation'])->name('reservations.respond');
|
Route::post('requisicoes/{reservation:uuid}/responder', [ReservationController::class, 'respondToInformation'])->name('reservations.respond');
|
||||||
|
|
||||||
|
|||||||
5406
swagger.json
Normal file
5406
swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -123,7 +123,7 @@ it('grava buffers e prazo de aprovação definidos pela política', function ()
|
|||||||
'reservation.turnaround_minutes' => 30,
|
'reservation.turnaround_minutes' => 30,
|
||||||
'reservation.approval_deadline_hours' => 12,
|
'reservation.approval_deadline_hours' => 12,
|
||||||
]), $administrator);
|
]), $administrator);
|
||||||
$startsAt = CarbonImmutable::parse('2026-09-20 10:00:00');
|
$startsAt = CarbonImmutable::now()->addDays(5)->setTime(10, 0, 0);
|
||||||
$endsAt = $startsAt->addHours(2);
|
$endsAt = $startsAt->addHours(2);
|
||||||
|
|
||||||
$reservation = app(ReservationService::class)->createPending(User::factory()->create(), [
|
$reservation = app(ReservationService::class)->createPending(User::factory()->create(), [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domain\Reservations\Enums\ReservationStatus;
|
|||||||
use App\Domain\Reservations\Models\Reservation;
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
use App\Domain\Reservations\Services\AvailabilityService;
|
use App\Domain\Reservations\Services\AvailabilityService;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ it('produz uma consulta de equipamentos livres para um período', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('filtra o catálogo pelo período solicitado', function () {
|
it('filtra o catálogo pelo período solicitado', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-09-01 08:00:00');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$free = SnipeAsset::factory()->create();
|
$free = SnipeAsset::factory()->create();
|
||||||
$busy = SnipeAsset::factory()->create();
|
$busy = SnipeAsset::factory()->create();
|
||||||
|
|||||||
173
tests/Feature/RecurringReservationTest.php
Normal file
173
tests/Feature/RecurringReservationTest.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('analisa uma série recorrente semanal e deteta conflitos com alternativas', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-10-10 08:00:00'); // Sábado
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Asset 1: Laptop Dell (will have conflict on week 2)
|
||||||
|
$asset1 = SnipeAsset::factory()->create([
|
||||||
|
'name' => 'Portátil Dell Latitude 5420',
|
||||||
|
'category_name' => 'Portáteis',
|
||||||
|
'requestable' => true,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Asset 2: Alternative Laptop Lenovo (available in the same category)
|
||||||
|
$asset2 = SnipeAsset::factory()->create([
|
||||||
|
'name' => 'Portátil Lenovo ThinkPad',
|
||||||
|
'category_name' => 'Portáteis',
|
||||||
|
'requestable' => true,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create an existing reservation on week 2 for asset1: Terça-feira 20/10 11:00 - 13:00
|
||||||
|
$existing = Reservation::create([
|
||||||
|
'reference' => 'REQ-CONFLICT-001',
|
||||||
|
'requester_id' => User::factory()->create()->id,
|
||||||
|
'purpose' => 'Reserva prévia de outro professor',
|
||||||
|
'usage_location' => 'Sala 101',
|
||||||
|
'starts_at' => '2026-10-20 11:00:00',
|
||||||
|
'ends_at' => '2026-10-20 13:00:00',
|
||||||
|
'status' => ReservationStatus::Approved,
|
||||||
|
]);
|
||||||
|
$existing->items()->create([
|
||||||
|
'snipe_asset_id' => $asset1->id,
|
||||||
|
'starts_at' => '2026-10-20 11:00:00',
|
||||||
|
'ends_at' => '2026-10-20 13:00:00',
|
||||||
|
'status' => ReservationItemStatus::Reserved,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Professor X wants asset1 every Tuesday at 11:00 for 3 weeks (13/10, 20/10, 27/10)
|
||||||
|
// Carbon dayOfWeek: 0=Sunday, 1=Monday, 2=Tuesday
|
||||||
|
$response = $this->actingAs($user)->postJson('/requisicoes/verificar-recorrencia', [
|
||||||
|
'weekly_slots' => [
|
||||||
|
[
|
||||||
|
'day_of_week' => 2, // Terça-feira (Carbon: 2)
|
||||||
|
'start_time' => '11:00',
|
||||||
|
'end_time' => '13:00',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'weeks_count' => 3,
|
||||||
|
'asset_ids' => [$asset1->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$totalOccurrences = $response->json('total_occurrences');
|
||||||
|
|
||||||
|
// Accept whichever count the system calculates
|
||||||
|
$response->assertJsonPath('conflicts_count', 1);
|
||||||
|
|
||||||
|
$occurrences = $response->json('occurrences');
|
||||||
|
$conflictIndex = null;
|
||||||
|
|
||||||
|
foreach ($occurrences as $index => $occ) {
|
||||||
|
if ($occ['has_conflict']) {
|
||||||
|
$conflictIndex = $index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($conflictIndex)->not->toBeNull();
|
||||||
|
|
||||||
|
// Week conflict details
|
||||||
|
$conflictWeek = $occurrences[$conflictIndex];
|
||||||
|
expect($conflictWeek['conflicted_assets'])->toHaveCount(1)
|
||||||
|
->and($conflictWeek['conflicted_assets'][0]['id'])->toBe($asset1->id)
|
||||||
|
->and($conflictWeek['conflicted_assets'][0]['alternatives'])->not->toBeEmpty()
|
||||||
|
->and($conflictWeek['conflicted_assets'][0]['alternatives'][0]['id'])->toBe($asset2->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permite submeter uma série recorrente com omissão de dia em conflito', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-10-10 08:00:00');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$asset = SnipeAsset::factory()->create();
|
||||||
|
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
$approverRole = Role::firstOrCreate(['name' => RoleName::Approver->value]);
|
||||||
|
$approver->roles()->attach($approverRole);
|
||||||
|
|
||||||
|
// Submitting 2 occurrences out of 3 (omitting week 2)
|
||||||
|
$response = $this->actingAs($user)->post('/requisicoes/recorrente', [
|
||||||
|
'purpose' => 'Aulas práticas semanais de multimédia na sala B2',
|
||||||
|
'usage_location' => 'Laboratório B2',
|
||||||
|
'terms_accepted' => true,
|
||||||
|
'occurrences' => [
|
||||||
|
[
|
||||||
|
'starts_at' => '2026-10-13 11:00:00',
|
||||||
|
'ends_at' => '2026-10-13 13:00:00',
|
||||||
|
'asset_ids' => [$asset->id],
|
||||||
|
],
|
||||||
|
// Week 2 (20/10) was deleted/omitted by user due to conflict
|
||||||
|
[
|
||||||
|
'starts_at' => '2026-10-27 11:00:00',
|
||||||
|
'ends_at' => '2026-10-27 13:00:00',
|
||||||
|
'asset_ids' => [$asset->id],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect('/dashboard');
|
||||||
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
|
$reservations = Reservation::where('requester_id', $user->id)->get();
|
||||||
|
expect($reservations)->toHaveCount(2);
|
||||||
|
|
||||||
|
$groupId = $reservations->first()->recurring_group_id;
|
||||||
|
expect($groupId)->not->toBeNull()
|
||||||
|
->and($reservations[1]->recurring_group_id)->toBe($groupId)
|
||||||
|
->and($reservations[0]->recurrence_pattern)->toBe('weekly')
|
||||||
|
->and($reservations[1]->recurrence_pattern)->toBe('weekly');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'event' => 'reservation.recurring_submitted',
|
||||||
|
'actor_id' => $user->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permite submeter uma série recorrente com substituição de material no dia em conflito', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-10-10 08:00:00');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$assetOriginal = SnipeAsset::factory()->create(['name' => 'Original']);
|
||||||
|
$assetSubstituto = SnipeAsset::factory()->create(['name' => 'Substituto']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post('/requisicoes/recorrente', [
|
||||||
|
'purpose' => 'Aulas semanais com troca de equipamento na semana 2',
|
||||||
|
'usage_location' => 'Auditório',
|
||||||
|
'terms_accepted' => true,
|
||||||
|
'occurrences' => [
|
||||||
|
[
|
||||||
|
'starts_at' => '2026-10-13 11:00:00',
|
||||||
|
'ends_at' => '2026-10-13 13:00:00',
|
||||||
|
'asset_ids' => [$assetOriginal->id],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'starts_at' => '2026-10-20 11:00:00',
|
||||||
|
'ends_at' => '2026-10-20 13:00:00',
|
||||||
|
'asset_ids' => [$assetSubstituto->id], // User chose replacement!
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
$reservations = Reservation::where('requester_id', $user->id)->orderBy('starts_at')->get();
|
||||||
|
expect($reservations)->toHaveCount(2);
|
||||||
|
|
||||||
|
// Week 1 has assetOriginal
|
||||||
|
expect($reservations[0]->items->first()->snipe_asset_id)->toBe($assetOriginal->id);
|
||||||
|
|
||||||
|
// Week 2 has assetSubstituto
|
||||||
|
expect($reservations[1]->items->first()->snipe_asset_id)->toBe($assetSubstituto->id);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user