1731 lines
98 KiB
TypeScript
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' },
|
|
],
|
|
};
|