Files
reservas/resources/js/pages/reservations/create.tsx
João Miranda fb04d71a8d
Some checks failed
tests / ci (push) Has been cancelled
first commit
2026-10-27 11:01:11 +00:00

1731 lines
98 KiB
TypeScript

import { Head, Link, router, useForm } from '@inertiajs/react';
import {
ArrowLeft,
CalendarClock,
Check,
Info,
MapPin,
PackageCheck,
RefreshCw,
Repeat,
Search,
CheckSquare,
Trash2,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading';
import { RecurringConflictModal } from '@/components/recurring-conflict-modal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { WeeklySlotSelector } from '@/components/weekly-slot-selector';
import type { Asset, OccurrenceCheck, WeeklySlot } from '@/types';
export default function CreateReservation({
assets,
selectedAssetId,
period,
periodSelected,
policy,
}: {
assets: Asset[];
selectedAssetId: number;
period: { starts_at?: string; ends_at?: string };
periodSelected: boolean;
policy: {
minNoticeHours: number;
maxDurationHours: number;
turnaroundMinutes: number;
maxAssets: number;
};
}) {
const form = useForm({
purpose: '',
usage_location: '',
starts_at: period.starts_at ?? '',
ends_at: period.ends_at ?? '',
asset_ids: selectedAssetId ? [selectedAssetId] : ([] as number[]),
terms_accepted: false,
});
const [search, setSearch] = useState('');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(),
);
// Slot selector mode for non-recurring reservations
const [useSlotSelector, setUseSlotSelector] = useState(true);
const [filteredAssets, setFilteredAssets] = useState<Asset[]>(useSlotSelector ? [] : assets);
// Recurrence states
const [isRecurring, setIsRecurring] = useState(false);
const [recurrenceMode, setRecurrenceMode] = useState<
'until_date' | 'weeks_count'
>('until_date');
const [repeatUntil, setRepeatUntil] = useState('');
const [weeksCount, setWeeksCount] = useState(4);
const [weeklySlots, setWeeklySlots] = useState<WeeklySlot[]>([]);
// Slot selector mode for recurring reservations
const [useRecurringSlotSelector, setUseRecurringSlotSelector] = useState(true);
const [slotAvailability, setSlotAvailability] = useState<
Map<string, boolean>
>(new Map());
// Week navigation for non-recurring reservations
const [nonRecurringWeekOffset, setNonRecurringWeekOffset] = useState(0);
// Week navigation for manual time entry
const [weekOffset, setWeekOffset] = useState(0);
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<number[]>([1]); // Default to Monday
const [showConflictModal, setShowConflictModal] = useState(false);
const [isCheckingRecurrence, setIsCheckingRecurrence] = useState(false);
const [checkResult, setCheckResult] = useState<{
occurrences: OccurrenceCheck[];
total_occurrences: number;
conflicts_count: number;
} | null>(null);
const [isSubmittingSeries, setIsSubmittingSeries] = useState(false);
const [generalError, setGeneralError] = useState<string>('');
// Helper function to get week date range
const getWeekDateRange = (offset: number) => {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // Monday
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 4); // Friday
const formatDate = (date: Date) => {
return date.toLocaleDateString('pt-PT', {
day: '2-digit',
month: '2-digit',
});
};
return `${formatDate(startOfWeek)}-${formatDate(endOfWeek)}`;
};
const recurringSummary = (() => {
if (weeklySlots.length === 0) {
return null;
}
const dayNames = {
1: 'Segunda',
2: 'Terça',
3: 'Quarta',
4: 'Quinta',
5: 'Sexta',
};
let count = weeksCount;
let untilDate = null;
if (recurrenceMode === 'until_date' && repeatUntil) {
const until = new Date(`${repeatUntil}T23:59:59`);
const now = new Date();
const diffDays = Math.max(
0,
Math.floor(
(until.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
),
);
count = Math.min(26, Math.max(1, Math.floor(diffDays / 7) + 1));
untilDate = repeatUntil;
}
// Don't show summary if in until_date mode but no date is selected
if (recurrenceMode === 'until_date' && !repeatUntil) {
return null;
}
const slotDescriptions = weeklySlots.map((slot) => {
const dayName =
dayNames[slot.day_of_week as keyof typeof dayNames] || 'Dia';
return `${dayName} das ${slot.start_time} às ${slot.end_time}`;
});
return {
slots: slotDescriptions,
weeks: count,
untilDate,
};
})();
// Filter by search
const searchFilteredAssets = assets.filter(
(asset) =>
asset.name.toLowerCase().includes(search.toLowerCase()) ||
(asset.model_name &&
asset.model_name
.toLowerCase()
.includes(search.toLowerCase())) ||
asset.asset_tag.toLowerCase().includes(search.toLowerCase()) ||
(asset.category_name &&
asset.category_name
.toLowerCase()
.includes(search.toLowerCase())),
);
// When recurring or using slot selector, also filter by availability (filteredAssets state)
// When not recurring and not using slot selector, just use search-filtered assets
const currentFilteredAssets = isRecurring || useSlotSelector
? searchFilteredAssets.filter((asset) =>
filteredAssets.some((a) => a.id === asset.id),
)
: searchFilteredAssets;
// Group assets by description (category + model)
const groupedAssets = currentFilteredAssets.reduce(
(groups, asset) => {
const description = `${asset.category_name} · ${asset.model_name}`;
if (!groups[description]) {
groups[description] = [];
}
groups[description].push(asset);
return groups;
},
{} as Record<string, Asset[]>,
);
const sortedGroups = Object.entries(groupedAssets).sort((a, b) => {
// Sort groups with multiple items first
if (a[1].length > 1 && b[1].length === 1) {
return -1;
}
if (a[1].length === 1 && b[1].length > 1) {
return 1;
}
// Then by description
return a[0].localeCompare(b[0]);
});
function toggleGroup(description: string) {
const groupAssets = groupedAssets[description];
const allSelected = groupAssets.every((asset) =>
form.data.asset_ids.includes(asset.id),
);
let newAssetIds = [...form.data.asset_ids];
if (allSelected) {
// Deselect all
newAssetIds = newAssetIds.filter(
(id) => !groupAssets.some((asset) => asset.id === id),
);
} else {
// Select all
groupAssets.forEach((asset) => {
if (!newAssetIds.includes(asset.id)) {
newAssetIds.push(asset.id);
}
});
}
form.setData('asset_ids', newAssetIds);
}
function toggleGroupExpansion(description: string) {
setExpandedGroups((prev) => {
const newSet = new Set(prev);
if (newSet.has(description)) {
newSet.delete(description);
} else {
newSet.add(description);
}
return newSet;
});
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!isRecurring) {
if (useSlotSelector) {
// Submit multiple reservations
if (weeklySlots.length === 0) {
setGeneralError('Selecione pelo menos um horário.');
return;
}
// Limpar erros anteriores antes de enviar
form.clearErrors();
setGeneralError('');
const xsrfToken = (() => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
})();
const response = await fetch('/requisicoes/multiplas', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({
weekly_slots: weeklySlots,
week_offset: nonRecurringWeekOffset,
purpose: form.data.purpose,
usage_location: form.data.usage_location,
asset_ids: form.data.asset_ids,
terms_accepted: form.data.terms_accepted,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => null);
// Se for erro de validação (422), mostrar erros por campo
if (response.status === 422 && errData?.errors) {
const fieldErrors = errData.errors as Record<string, string[]>;
const knownFields = ['purpose', 'usage_location', 'asset_ids', 'terms_accepted'] as const;
const mappedErrors: Partial<Record<typeof knownFields[number], string>> = {};
let hasFieldError = false;
for (const field of knownFields) {
if (fieldErrors[field]?.length) {
mappedErrors[field] = fieldErrors[field][0];
hasFieldError = true;
}
}
if (hasFieldError) {
form.setError(mappedErrors);
return;
}
}
setGeneralError(
errData?.message || 'Erro ao criar requisições.',
);
return;
}
router.get('/dashboard');
return;
}
form.post('/requisicoes', {
preserveState: true,
preserveScroll: true,
});
return;
}
// Validate basic fields before launching modal
if (weeklySlots.length === 0) {
setGeneralError('Adicione pelo menos um horário semanal.');
return;
}
// Clear previous errors
form.clearErrors();
setGeneralError('');
setShowConflictModal(true);
setIsCheckingRecurrence(true);
setCheckResult(null);
try {
const xsrfToken = (() => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
})();
const response = await fetch('/requisicoes/verificar-recorrencia', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({
weekly_slots: weeklySlots,
repeat_until:
recurrenceMode === 'until_date' ? repeatUntil : null,
weeks_count:
recurrenceMode === 'weeks_count' ? weeksCount : null,
asset_ids: form.data.asset_ids,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => null);
throw new Error(
errData?.message ||
'Erro ao verificar disponibilidade da série.',
);
}
const data = await response.json();
setCheckResult(data);
} catch (err) {
console.error(err);
setShowConflictModal(false);
setGeneralError(
err instanceof Error
? err.message
: 'Erro ao verificar repetição semanal.',
);
} finally {
setIsCheckingRecurrence(false);
}
}
function handleConfirmSeries(
occurrencesToSubmit: Array<{
starts_at: string;
ends_at: string;
asset_ids: number[];
}>,
) {
setIsSubmittingSeries(true);
router.post(
'/requisicoes/recorrente',
{
purpose: form.data.purpose,
usage_location: form.data.usage_location,
terms_accepted: form.data.terms_accepted,
occurrences: occurrencesToSubmit,
},
{
preserveState: true,
preserveScroll: true,
onFinish: () => {
setIsSubmittingSeries(false);
setShowConflictModal(false);
},
},
);
}
function toggleAsset(id: number, checked: boolean) {
form.setData(
'asset_ids',
checked
? [...form.data.asset_ids, id]
: form.data.asset_ids.filter((item) => item !== id),
);
setGeneralError('');
// Check slot availability when using slot selector
if (useSlotSelector || isRecurring) {
checkSlotAvailability();
}
}
// Update availability when week offset changes
function handleWeekOffsetChange(newOffset: number) {
if (useSlotSelector) {
setNonRecurringWeekOffset(newOffset);
} else {
setWeekOffset(newOffset);
}
if (useSlotSelector || isRecurring) {
refreshWeeklyAvailability();
} else {
checkSlotAvailability();
}
}
function refreshAvailability() {
router.get('/requisicoes/nova', {
starts_at: form.data.starts_at,
ends_at: form.data.ends_at,
});
}
async function refreshWeeklyAvailability() {
if (weeklySlots.length === 0) {
setFilteredAssets([]);
setSlotAvailability(new Map());
return;
}
try {
const xsrfToken = (() => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
})();
const weekOffset = isRecurring ? 0 : nonRecurringWeekOffset;
const response = await fetch(
'/requisicoes/verificar-disponibilidade-semanal',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({
weekly_slots: weeklySlots,
week_offset: weekOffset,
}),
},
);
if (!response.ok) {
const errData = await response.json().catch(() => null);
throw new Error(
errData?.message ||
'Erro ao verificar disponibilidade semanal.',
);
}
const data = await response.json();
setFilteredAssets(data.assets);
} catch (err) {
console.error(err);
setGeneralError(
err instanceof Error
? err.message
: 'Erro ao verificar disponibilidade semanal.',
);
}
}
async function checkSlotAvailability() {
if (form.data.asset_ids.length === 0) {
setSlotAvailability(new Map());
return;
}
try {
const xsrfToken = (() => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
})();
const weekOffset = isRecurring ? 0 : nonRecurringWeekOffset;
const response = await fetch(
'/requisicoes/verificar-disponibilidade-slots',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({
weekly_slots: weeklySlots,
asset_ids: form.data.asset_ids,
week_offset: weekOffset,
}),
},
);
if (!response.ok) {
const errData = await response.json().catch(() => null);
throw new Error(
errData?.message ||
'Erro ao verificar disponibilidade de slots.',
);
}
const data = await response.json();
setSlotAvailability(new Map(Object.entries(data.availability)));
} catch (err) {
console.error(err);
// Don't show alert for this one, just clear availability
setSlotAvailability(new Map());
}
}
return (
<>
<Head title="Nova requisição" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Novo pedido"
title="Criar requisição"
description="Selecione o período, os equipamentos e indique como serão utilizados."
action={
<Button asChild variant="ghost">
<Link href="/equipamentos">
<ArrowLeft /> Voltar ao catálogo
</Link>
</Button>
}
/>
<Card className="border-primary/20 bg-primary/5 dark:border-primary/10 dark:bg-primary/5">
<CardContent className="flex items-start gap-3 p-4">
<Info className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="text-sm">
<p className="font-medium text-foreground">
Regras atuais
</p>
<p className="mt-1 text-muted-foreground">
Antecedência mínima de {policy.minNoticeHours}{' '}
h, duração máxima de {policy.maxDurationHours}{' '}
h, até {policy.maxAssets} equipamentos
{policy.turnaroundMinutes > 0
? ` e margem operacional de ${policy.turnaroundMinutes} min.`
: '.'}
</p>
</div>
</CardContent>
</Card>
{generalError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
{generalError}
</div>
)}
<form
onSubmit={submit}
noValidate
className="grid gap-6 lg:grid-cols-[1fr_340px]"
>
<div className="space-y-6">
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarClock className="size-5 text-primary" />{' '}
Período e utilização
</CardTitle>
</CardHeader>
<CardContent className="grid gap-5 sm:grid-cols-2">
{!isRecurring && (
<>
<div className="flex items-center gap-3 sm:col-span-2">
<Checkbox
id="use_manual_datetime"
checked={!useSlotSelector}
onCheckedChange={(val) => {
setUseSlotSelector(
!val,
);
if (!val) {
setWeeklySlots([]);
setSlotAvailability(
new Map(),
);
setFilteredAssets(assets);
} else {
setFilteredAssets([]);
}
}}
/>
<Label
htmlFor="use_manual_datetime"
className="flex cursor-pointer items-center gap-1.5 font-medium"
>
Usar campos manuais de data/hora
</Label>
</div>
</>
)}
{!isRecurring && !useSlotSelector && (
<>
<Field
label="Início"
error={form.errors.starts_at}
>
<Input
type="datetime-local"
value={form.data.starts_at}
onChange={(e) => {
const val = e.target.value;
form.setData(
'starts_at',
val,
);
form.clearErrors('starts_at');
if (!repeatUntil && val) {
try {
const date =
new Date(val);
if (
!isNaN(
date.getTime(),
)
) {
date.setDate(
date.getDate() +
28,
);
// Use local timezone instead of UTC
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
setRepeatUntil(
`${year}-${month}-${day}`,
);
}
} catch {
// ignore
}
}
}}
className="h-11"
/>
</Field>
<Field
label="Fim"
error={form.errors.ends_at}
>
<Input
type="datetime-local"
value={form.data.ends_at}
onChange={(e) => {
form.setData(
'ends_at',
e.target.value,
);
form.clearErrors('ends_at');
}}
className="h-11"
/>
</Field>
</>
)}
{!isRecurring && useSlotSelector && (
<div className="sm:col-span-2 space-y-4">
<div className="flex items-center justify-between">
<Label>Selecione os horários</Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
handleWeekOffsetChange(
Math.max(
0,
nonRecurringWeekOffset -
1,
),
)
}
disabled={
nonRecurringWeekOffset ===
0
}
>
←
</Button>
<span className="text-sm font-medium">
{getWeekDateRange(
nonRecurringWeekOffset,
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
handleWeekOffsetChange(
nonRecurringWeekOffset +
1,
)
}
>
→
</Button>
</div>
</div>
<WeeklySlotSelector
weeklySlots={weeklySlots}
onChange={setWeeklySlots}
slotAvailability={slotAvailability}
weekOffset={nonRecurringWeekOffset}
/>
</div>
)}
<div className="space-y-4 rounded-lg border bg-slate-50/70 p-4 sm:col-span-2 dark:bg-slate-900/50">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Checkbox
id="is_recurring"
checked={isRecurring}
onCheckedChange={(val) => {
setIsRecurring(
Boolean(val),
);
if (val) {
form.setData(
'starts_at',
'',
);
form.setData(
'ends_at',
'',
);
setFilteredAssets([]);
setUseSlotSelector(true);
setUseRecurringSlotSelector(
true,
);
} else {
setFilteredAssets(
useSlotSelector
? []
: assets,
);
}
}}
/>
<Label
htmlFor="is_recurring"
className="flex cursor-pointer items-center gap-1.5 font-medium"
>
<Repeat className="size-4 text-primary" />
Repetir semanalmente
</Label>
</div>
<p className="pl-6 text-xs text-muted-foreground">
Defina múltiplos horários
semanais para repetir durante
várias semanas (ex.: aulas em
dias diferentes).
</p>
</div>
</div>
{isRecurring && (
<div className="space-y-4 border-t pt-3 pl-6">
<div className="flex items-center gap-3">
<Checkbox
id="use_recurring_manual"
checked={!useRecurringSlotSelector}
onCheckedChange={(val) => {
setUseRecurringSlotSelector(
!val,
);
if (!val) {
setWeeklySlots([]);
setFilteredAssets([]);
}
}}
/>
<Label
htmlFor="use_recurring_manual"
className="flex cursor-pointer items-center gap-1.5 font-medium"
>
Usar campos manuais de horário
</Label>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
<input
type="radio"
name="recurrence_mode"
checked={
recurrenceMode ===
'until_date'
}
onChange={() =>
setRecurrenceMode(
'until_date',
)
}
className="text-primary focus:ring-primary"
/>
Até à data limite
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
<input
type="radio"
name="recurrence_mode"
checked={
recurrenceMode ===
'weeks_count'
}
onChange={() =>
setRecurrenceMode(
'weeks_count',
)
}
className="text-primary focus:ring-primary"
/>
Número de semanas
</label>
</div>
{recurrenceMode === 'until_date' ? (
<div>
<Label
htmlFor="repeat_until"
className="text-xs"
>
Repetir até ao dia
</Label>
<Input
id="repeat_until"
type="date"
value={repeatUntil}
onChange={(e) =>
setRepeatUntil(
e.target.value,
)
}
className="mt-1.5 h-10 max-w-xs"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Ex.: término do semestre
ou período letivo.
</p>
</div>
) : (
<div>
<Label
htmlFor="weeks_count"
className="text-xs"
>
Número de semanas
</Label>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<Input
id="weeks_count"
type="number"
min={2}
max={26}
value={weeksCount}
onChange={(e) =>
setWeeksCount(
Math.max(
1,
parseInt(
e
.target
.value,
10,
) || 1,
),
)
}
className="h-10 w-24"
/>
<div className="flex items-center gap-1">
{[2, 4, 8, 12].map(
(num) => (
<Button
key={
num
}
type="button"
variant={
weeksCount ===
num
? 'secondary'
: 'outline'
}
size="sm"
onClick={() =>
setWeeksCount(
num,
)
}
className="h-8 px-2.5 text-xs"
>
{num}{' '}
sem.
</Button>
),
)}
</div>
</div>
</div>
)}
{!useRecurringSlotSelector && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Selecione os dias da semana</Label>
</div>
<div className="flex flex-wrap gap-4">
{[
{ value: 1, label: 'Segunda' },
{ value: 2, label: 'Terça' },
{ value: 3, label: 'Quarta' },
{ value: 4, label: 'Quinta' },
{ value: 5, label: 'Sexta' },
].map((day) => (
<label
key={day.value}
className="flex items-center gap-2 text-sm"
>
<Checkbox
checked={selectedDaysOfWeek.includes(
day.value,
)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedDaysOfWeek([
...selectedDaysOfWeek,
day.value,
]);
} else {
setSelectedDaysOfWeek(
selectedDaysOfWeek.filter(
(d) =>
d !==
day.value,
),
);
}
}}
/>
{day.label}
</label>
))}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Horário</Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
handleWeekOffsetChange(
Math.max(
0,
weekOffset -
1,
),
)
}
disabled={
weekOffset ===
0
}
>
←
</Button>
<span className="text-sm font-medium">
{getWeekDateRange(
weekOffset,
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
handleWeekOffsetChange(
weekOffset +
1,
)
}
>
→
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label="Início"
error={
form.errors
.starts_at
}
>
<Input
type="time"
value={
form.data
.starts_at
}
onChange={(e) => {
form.setData(
'starts_at',
e.target
.value,
);
form.clearErrors('starts_at');
}}
className="h-11"
/>
</Field>
<Field
label="Fim"
error={
form.errors
.ends_at
}
>
<Input
type="time"
value={
form.data
.ends_at
}
onChange={(e) => {
form.setData(
'ends_at',
e.target
.value,
);
form.clearErrors('ends_at');
}}
className="h-11"
/>
</Field>
</div>
<Button
type="button"
variant="secondary"
onClick={() => {
if (
selectedDaysOfWeek.length ===
0 ||
!form.data
.starts_at ||
!form.data
.ends_at
) {
setGeneralError(
'Selecione pelo menos um dia da semana e defina o horário.',
);
return;
}
const newSlots = selectedDaysOfWeek.map(
(day) => ({
day_of_week:
day,
start_time:
form.data
.starts_at,
end_time:
form.data
.ends_at,
}),
);
setWeeklySlots([
...weeklySlots,
...newSlots,
]);
}}
>
Adicionar horário
</Button>
</div>
{weeklySlots.length > 0 && (
<div className="space-y-2">
<Label>
Horários adicionados
</Label>
<div className="space-y-2">
{weeklySlots.map(
(
slot,
index,
) => (
<div
key={
index
}
className="flex items-center justify-between rounded-lg border bg-white p-3 dark:bg-slate-800"
>
<span className="text-sm">
{
[
'',
'Segunda',
'Terça',
'Quarta',
'Quinta',
'Sexta',
][
slot.day_of_week
]
}{' '}
das{' '}
{
slot.start_time
}{' '}
às{' '}
{
slot.end_time
}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setWeeklySlots(
weeklySlots.filter(
(
_,
i,
) =>
i !==
index,
),
)
}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</div>
),
)}
</div>
</div>
)}
</div>
)}
{useRecurringSlotSelector && (
<WeeklySlotSelector
weeklySlots={weeklySlots}
onChange={setWeeklySlots}
slotAvailability={
slotAvailability
}
weekOffset={0}
/>
)}
{recurringSummary && (
<div className="flex items-start gap-2.5 rounded-md border border-primary/20 bg-primary/5 p-3 text-xs text-foreground">
<CalendarClock className="mt-0.5 size-4 shrink-0 text-primary" />
<div className="space-y-1">
<p>
📅{' '}
{recurringSummary.slots.join(
', ',
)}
</p>
{recurrenceMode ===
'until_date' ? (
<p>
Até ao dia{' '}
<strong>
{
recurringSummary.untilDate
}
</strong>
.
</p>
) : (
<p>
Num total de{' '}
<strong>
{
recurringSummary.weeks
}{' '}
semanas
</strong>
.
</p>
)}
</div>
</div>
)}
</div>
)}
</div>
<div className="sm:col-span-2">
<div>
<Label htmlFor="usage_location">
Local de utilização
</Label>
<div className="mt-2">
<Input
id="usage_location"
value={form.data.usage_location}
onChange={(e) => {
form.setData(
'usage_location',
e.target.value,
);
setGeneralError('');
}}
placeholder="Ex.: Auditório principal"
className="h-11"
/>
</div>
<InputError message={form.errors.usage_location} />
</div>
</div>
{(isRecurring || useSlotSelector) && (
<div className="sm:col-span-2">
<Button
type="button"
variant="secondary"
onClick={refreshWeeklyAvailability}
disabled={weeklySlots.length === 0}
>
<RefreshCw /> Verificar equipamentos
livres
</Button>
<p className="mt-2 text-xs text-muted-foreground">
Clique para verificar quais equipamentos
estão disponíveis para os horários
selecionados.
</p>
</div>
)}
{!isRecurring && !useSlotSelector && (
<div className="sm:col-span-2">
<Button
type="button"
variant="secondary"
onClick={refreshAvailability}
disabled={
!form.data.starts_at ||
!form.data.ends_at
}
>
<RefreshCw /> Verificar equipamentos
livres
</Button>
<p className="mt-2 text-xs text-muted-foreground">
Confirme a disponibilidade sempre
que alterar o período.
</p>
</div>
)}
<div className="sm:col-span-2">
<Label htmlFor="purpose">Finalidade</Label>
<textarea
id="purpose"
value={form.data.purpose}
onChange={(e) => {
form.setData(
'purpose',
e.target.value,
);
setGeneralError('');
}}
rows={4}
placeholder="Descreva brevemente a atividade e a necessidade do equipamento…"
className="mt-2 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<InputError message={form.errors.purpose} />
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PackageCheck className="size-5 text-primary" />{' '}
{periodSelected
? 'Equipamentos livres no período'
: 'Equipamentos requisitáveis'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) =>
setSearch(e.target.value)
}
placeholder="Pesquisar equipamentos por nome, modelo ou etiqueta…"
className="pl-10"
/>
</div>
{filteredAssets.length === 0 && (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{search
? 'Nenhum equipamento encontrado com a pesquisa.'
: weeklySlots.length === 0
? 'Selecione horários e clique em "Verificar equipamentos livres" para ver os equipamentos disponíveis.'
: 'Não existem equipamentos livres para o período escolhido.'}
</p>
)}
{sortedGroups.map(
([description, groupAssets]) => {
const isGroup = groupAssets.length > 1;
const selectedInGroup =
groupAssets.filter((asset) =>
form.data.asset_ids.includes(
asset.id,
),
).length;
const isExpanded =
expandedGroups.has(description);
return (
<div
key={description}
className="space-y-2"
>
{isGroup ? (
<details
open={isExpanded}
onToggle={(e) => {
if (
e.currentTarget
.open
) {
toggleGroupExpansion(
description,
);
} else {
toggleGroupExpansion(
description,
);
}
}}
className="group/details"
>
<summary className="flex cursor-pointer items-center justify-between rounded-lg border bg-slate-50 p-3 transition-colors hover:bg-slate-100 dark:bg-slate-900 dark:hover:bg-slate-800">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{
description
}
</span>
<span className="text-xs text-muted-foreground">
(
{
groupAssets.length
}{' '}
disponíveis)
</span>
</div>
<div className="flex items-center gap-3">
{selectedInGroup >
0 && (
<span className="text-sm font-medium text-primary">
{
selectedInGroup
}{' '}
selecionado
{selectedInGroup !==
1
? 's'
: ''}
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={(
e,
) => {
e.preventDefault();
toggleGroup(
description,
);
}}
className="h-8"
>
<CheckSquare className="size-4" />
{selectedInGroup ===
groupAssets.length
? 'Desmarcar todos'
: 'Selecionar todos'}
</Button>
</div>
</summary>
<div className="mt-2 space-y-2 pl-4">
{groupAssets.map(
(asset) => {
const checked =
form.data.asset_ids.includes(
asset.id,
);
return (
<label
key={
asset.id
}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-all ${checked ? 'border-primary bg-primary/5 shadow-sm' : 'hover:border-slate-300 hover:bg-slate-50 dark:hover:bg-slate-900'}`}
>
<Checkbox
checked={
checked
}
onCheckedChange={(
value,
) =>
toggleAsset(
asset.id,
Boolean(
value,
),
)
}
/>
<div className="min-w-0 flex-1">
<div className="flex justify-between gap-3">
<span className="font-medium">
{
asset.name
}
</span>
<span className="rounded bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground">
{
asset.asset_tag
}
</span>
</div>
{asset.location_name && (
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />
{
asset.location_name
}
</p>
)}
</div>
</label>
);
},
)}
</div>
</details>
) : (
groupAssets.map((asset) => {
const checked =
form.data.asset_ids.includes(
asset.id,
);
return (
<label
key={asset.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-all ${checked ? 'border-primary bg-primary/5 shadow-sm' : 'hover:border-slate-300 hover:bg-slate-50 dark:hover:bg-slate-900'}`}
>
<Checkbox
checked={
checked
}
onCheckedChange={(
value,
) =>
toggleAsset(
asset.id,
Boolean(
value,
),
)
}
/>
<div className="min-w-0 flex-1">
<div className="flex justify-between gap-3">
<span className="font-medium">
{
asset.name
}
</span>
<span className="rounded bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground">
{
asset.asset_tag
}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{
asset.category_name
}{' '}
·{' '}
{
asset.model_name
}
</p>
{asset.location_name && (
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />
{
asset.location_name
}
</p>
)}
</div>
</label>
);
})
)}
</div>
);
},
)}
<InputError message={form.errors.asset_ids} />
{search && filteredAssets.length === 0 && (
<Button
variant="link"
onClick={() => setSearch('')}
className="mt-2 text-sm"
>
Limpar pesquisa
</Button>
)}
</CardContent>
</Card>
</div>
<aside>
<Card className="sticky top-6 border-slate-200 shadow-none dark:border-slate-800">
<CardHeader>
<CardTitle>Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Disponíveis
</p>
<span className="text-2xl font-bold">
{currentFilteredAssets.length}
</span>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Selecionados
</p>
<span className="text-2xl font-bold">
{form.data.asset_ids.length}
</span>
</div>
<div className="flex items-start gap-3 border-t pt-5">
<Checkbox
id="terms"
checked={form.data.terms_accepted}
onCheckedChange={(value) => {
form.setData(
'terms_accepted',
Boolean(value),
);
setGeneralError('');
}}
/>
<Label
htmlFor="terms"
className="text-xs leading-5 font-normal"
>
Confirmo que conheço as condições de
utilização e que entregarei os
equipamentos no estado e prazo
acordados.
</Label>
</div>
<InputError message={form.errors.terms_accepted} />
<Button
type="submit"
className="h-11 w-full"
disabled={
form.processing ||
isCheckingRecurrence ||
isSubmittingSeries
}
>
{isRecurring ? (
<>
<Repeat className="mr-1.5 size-4" />
{isCheckingRecurrence
? 'A verificar série…'
: 'Verificar série e submeter'}
</>
) : useSlotSelector ? (
<>
<Check />{' '}
{form.processing
? 'A submeter…'
: `Criar ${weeklySlots.length} requisição${weeklySlots.length > 1 ? 'ões' : ''}`}
</>
) : (
<>
<Check />{' '}
{form.processing
? 'A submeter…'
: 'Submeter para aprovação'}
</>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
{isRecurring
? 'Cada semana será analisada quanto à disponibilidade.'
: useSlotSelector
? `Serão criadas ${weeklySlots.length} requisição${weeklySlots.length > 1 ? 'ões' : ''} separadas, uma por horário selecionado.`
: 'O equipamento fica pré-reservado até à decisão.'}
</p>
</CardContent>
</Card>
</aside>
</form>
<RecurringConflictModal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
isLoading={isCheckingRecurrence}
checkResult={checkResult}
initialAssetIds={form.data.asset_ids}
isSubmitting={isSubmittingSeries}
onConfirmSubmit={handleConfirmSeries}
/>
</main>
</>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<Label>{label}</Label>
<div className="mt-2">{children}</div>
<InputError message={error} />
</div>
);
}
CreateReservation.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Nova', href: '/requisicoes/nova' },
],
};