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>( new Set(), ); // Slot selector mode for non-recurring reservations const [useSlotSelector, setUseSlotSelector] = useState(true); const [filteredAssets, setFilteredAssets] = useState(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([]); // Slot selector mode for recurring reservations const [useRecurringSlotSelector, setUseRecurringSlotSelector] = useState(true); const [slotAvailability, setSlotAvailability] = useState< Map >(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([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(''); // 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, ); 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; const knownFields = ['purpose', 'usage_location', 'asset_ids', 'terms_accepted'] as const; const mappedErrors: Partial> = {}; 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 ( <>
Voltar ao catálogo } />

Regras atuais

Antecedência mínima de {policy.minNoticeHours}{' '} h, duração máxima de {policy.maxDurationHours}{' '} h, até {policy.maxAssets} equipamentos {policy.turnaroundMinutes > 0 ? ` e margem operacional de ${policy.turnaroundMinutes} min.` : '.'}

{generalError && (
{generalError}
)}
{' '} Período e utilização {!isRecurring && ( <>
{ setUseSlotSelector( !val, ); if (!val) { setWeeklySlots([]); setSlotAvailability( new Map(), ); setFilteredAssets(assets); } else { setFilteredAssets([]); } }} />
)} {!isRecurring && !useSlotSelector && ( <> { 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" /> { form.setData( 'ends_at', e.target.value, ); form.clearErrors('ends_at'); }} className="h-11" /> )} {!isRecurring && useSlotSelector && (
{getWeekDateRange( nonRecurringWeekOffset, )}
)}
{ setIsRecurring( Boolean(val), ); if (val) { form.setData( 'starts_at', '', ); form.setData( 'ends_at', '', ); setFilteredAssets([]); setUseSlotSelector(true); setUseRecurringSlotSelector( true, ); } else { setFilteredAssets( useSlotSelector ? [] : assets, ); } }} />

Defina múltiplos horários semanais para repetir durante várias semanas (ex.: aulas em dias diferentes).

{isRecurring && (
{ setUseRecurringSlotSelector( !val, ); if (!val) { setWeeklySlots([]); setFilteredAssets([]); } }} />
{recurrenceMode === 'until_date' ? (
setRepeatUntil( e.target.value, ) } className="mt-1.5 h-10 max-w-xs" />

Ex.: término do semestre ou período letivo.

) : (
setWeeksCount( Math.max( 1, parseInt( e .target .value, 10, ) || 1, ), ) } className="h-10 w-24" />
{[2, 4, 8, 12].map( (num) => ( ), )}
)} {!useRecurringSlotSelector && (
{[ { value: 1, label: 'Segunda' }, { value: 2, label: 'Terça' }, { value: 3, label: 'Quarta' }, { value: 4, label: 'Quinta' }, { value: 5, label: 'Sexta' }, ].map((day) => ( ))}
{getWeekDateRange( weekOffset, )}
{ form.setData( 'starts_at', e.target .value, ); form.clearErrors('starts_at'); }} className="h-11" /> { form.setData( 'ends_at', e.target .value, ); form.clearErrors('ends_at'); }} className="h-11" />
{weeklySlots.length > 0 && (
{weeklySlots.map( ( slot, index, ) => (
{ [ '', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', ][ slot.day_of_week ] }{' '} das{' '} { slot.start_time }{' '} às{' '} { slot.end_time }
), )}
)}
)} {useRecurringSlotSelector && ( )} {recurringSummary && (

📅{' '} {recurringSummary.slots.join( ', ', )}

{recurrenceMode === 'until_date' ? (

Até ao dia{' '} { recurringSummary.untilDate } .

) : (

Num total de{' '} { recurringSummary.weeks }{' '} semanas .

)}
)}
)}
{ form.setData( 'usage_location', e.target.value, ); setGeneralError(''); }} placeholder="Ex.: Auditório principal" className="h-11" />
{(isRecurring || useSlotSelector) && (

Clique para verificar quais equipamentos estão disponíveis para os horários selecionados.

)} {!isRecurring && !useSlotSelector && (

Confirme a disponibilidade sempre que alterar o período.

)}