feat: complete reservation lifecycle

This commit is contained in:
2026-08-04 16:30:11 +01:00
parent 077015f17a
commit e667d05bc1
24 changed files with 1096 additions and 46 deletions

View File

@@ -0,0 +1,209 @@
import { Head, Link } from '@inertiajs/react';
import { ArrowLeft, ArrowRight, CalendarDays, Plus } from 'lucide-react';
import { PageHeading } from '@/components/page-heading';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Reservation } from '@/types';
const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
export default function ReservationCalendar({
month,
reservations,
}: {
month: string;
reservations: Reservation[];
}) {
const [year, monthNumber] = month.split('-').map(Number);
const monthStart = new Date(year, monthNumber - 1, 1);
const firstWeekday = (monthStart.getDay() + 6) % 7;
const daysInMonth = new Date(year, monthNumber, 0).getDate();
const cellCount = Math.ceil((firstWeekday + daysInMonth) / 7) * 7;
const gridStart = new Date(year, monthNumber - 1, 1 - firstWeekday);
const days = Array.from({ length: cellCount }, (_, index) => {
const date = new Date(gridStart);
date.setDate(gridStart.getDate() + index);
return date;
});
const title = new Intl.DateTimeFormat('pt-PT', {
month: 'long',
year: 'numeric',
}).format(monthStart);
const previousMonth = monthKey(new Date(year, monthNumber - 2, 1));
const nextMonth = monthKey(new Date(year, monthNumber, 1));
return (
<>
<Head title="O meu calendário" />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Planeamento pessoal"
title="O meu calendário"
description="Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos."
action={
<Button asChild>
<Link href="/requisicoes/nova">
<Plus /> Nova requisição
</Link>
</Button>
}
/>
<section className="overflow-hidden rounded-xl border bg-card">
<header className="flex items-center justify-between border-b p-4">
<Button asChild variant="outline" size="sm">
<Link
href={`/requisicoes/calendario?month=${previousMonth}`}
>
<ArrowLeft /> Anterior
</Link>
</Button>
<h2 className="text-lg font-semibold capitalize">
{title}
</h2>
<Button asChild variant="outline" size="sm">
<Link
href={`/requisicoes/calendario?month=${nextMonth}`}
>
Seguinte <ArrowRight />
</Link>
</Button>
</header>
<div className="grid grid-cols-7 border-b bg-slate-50 text-center text-xs font-medium text-muted-foreground uppercase dark:bg-slate-900">
{weekdays.map((day) => (
<div key={day} className="px-1 py-3">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{days.map((day) => {
const dayStart = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
);
const dayEnd = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate() + 1,
);
const entries = reservations.filter(
(reservation) =>
new Date(reservation.starts_at) < dayEnd &&
new Date(reservation.ends_at) > dayStart,
);
const isCurrentMonth =
day.getMonth() === monthNumber - 1;
const isToday = sameDay(day, new Date());
return (
<div
key={day.toISOString()}
className={cn(
'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2',
!isCurrentMonth &&
'bg-slate-50/70 text-muted-foreground dark:bg-slate-950/40',
)}
>
<span
className={cn(
'inline-flex size-7 items-center justify-center rounded-full text-xs',
isToday &&
'bg-primary font-semibold text-primary-foreground',
)}
>
{day.getDate()}
</span>
<div className="mt-1 space-y-1">
{entries
.slice(0, 3)
.map((reservation) => (
<div
key={reservation.id}
title={`${reservation.reference} — ${reservation.items.map((item) => item.asset.name).join(', ')}`}
className={cn(
'truncate rounded border-l-2 bg-slate-100 px-1.5 py-1 text-[10px] font-medium sm:text-xs dark:bg-slate-900',
statusBorder(
reservation.status,
),
)}
>
{reservation.reference}
</div>
))}
{entries.length > 3 && (
<p className="px-1 text-[10px] text-muted-foreground">
+{entries.length - 3} pedido(s)
</p>
)}
</div>
</div>
);
})}
</div>
</section>
<div className="flex flex-wrap justify-center gap-4 text-xs text-muted-foreground">
<Legend color="bg-amber-500" label="Pendente" />
<Legend
color="bg-emerald-500"
label="Confirmada ou em curso"
/>
<Legend
color="bg-slate-400"
label="Fechada sem utilização"
/>
</div>
{reservations.length === 0 && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<CalendarDays className="size-4" /> Não existem
requisições neste mês.
</div>
)}
</main>
</>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className={cn('size-2 rounded-full', color)} /> {label}
</span>
);
}
function monthKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function statusBorder(status: string): string {
if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) {
return 'border-slate-400 opacity-60';
}
if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) {
return 'border-emerald-500';
}
return 'border-amber-500';
}
ReservationCalendar.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Calendário', href: '/requisicoes/calendario' },
],
};

View File

@@ -1,4 +1,4 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Head, Link, router, useForm } from '@inertiajs/react';
import {
ArrowLeft,
CalendarClock,
@@ -19,15 +19,19 @@ import type { Asset } from '@/types';
export default function CreateReservation({
assets,
selectedAssetId,
period,
periodSelected,
}: {
assets: Asset[];
selectedAssetId: number;
period: { starts_at?: string; ends_at?: string };
periodSelected: boolean;
}) {
const form = useForm({
purpose: '',
usage_location: '',
starts_at: '',
ends_at: '',
starts_at: period.starts_at ?? '',
ends_at: period.ends_at ?? '',
asset_ids: selectedAssetId ? [selectedAssetId] : ([] as number[]),
terms_accepted: false,
});
@@ -43,6 +47,12 @@ export default function CreateReservation({
: form.data.asset_ids.filter((item) => item !== id),
);
}
function refreshAvailability() {
router.get('/requisicoes/nova', {
starts_at: form.data.starts_at,
ends_at: form.data.ends_at,
});
}
return (
<>
@@ -117,6 +127,24 @@ export default function CreateReservation({
/>
</Field>
</div>
<div className="sm:col-span-2">
<Button
type="button"
variant="secondary"
onClick={refreshAvailability}
disabled={
!form.data.starts_at ||
!form.data.ends_at
}
>
<CalendarClock /> 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
@@ -140,10 +168,18 @@ export default function CreateReservation({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PackageCheck className="size-5 text-primary" />{' '}
Equipamentos
{periodSelected
? 'Equipamentos livres no período'
: 'Equipamentos requisitáveis'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{assets.length === 0 && (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
Não existem equipamentos livres para o
período escolhido.
</p>
)}
{assets.map((asset) => {
const checked =
form.data.asset_ids.includes(asset.id);

View File

@@ -1,8 +1,19 @@
import { Head, Link } from '@inertiajs/react';
import { CalendarDays, Plus } from 'lucide-react';
import { Head, Link, useForm } from '@inertiajs/react';
import { Ban, CalendarDays, Plus } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading';
import { ReservationStatus } from '@/components/reservation-status';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import type { Paginated, Reservation } from '@/types';
export default function ReservationsIndex({
@@ -10,10 +21,38 @@ export default function ReservationsIndex({
}: {
reservations: Paginated<Reservation>;
}) {
const [selected, setSelected] = useState<Reservation | null>(null);
const cancelForm = useForm({ reason: '' });
const format = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
});
const cancellableStatuses = [
'draft',
'pending_approval',
'awaiting_information',
'approved',
'ready',
];
function openCancellation(reservation: Reservation) {
cancelForm.reset();
cancelForm.clearErrors();
setSelected(reservation);
}
function submitCancellation(event: React.FormEvent) {
event.preventDefault();
if (!selected) {
return;
}
cancelForm.post(`/requisicoes/${selected.uuid}/cancelar`, {
preserveScroll: true,
onSuccess: () => setSelected(null),
});
}
return (
<>
@@ -33,16 +72,17 @@ export default function ReservationsIndex({
/>
{reservations.data.length ? (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[150px_1fr_220px_170px] 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">
<span>Referência</span>
<span>Equipamentos</span>
<span>Período</span>
<span>Estado</span>
<span>Ações</span>
</div>
{reservations.data.map((reservation) => (
<div
key={reservation.id}
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_170px] 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">
{reservation.reference}
@@ -72,6 +112,22 @@ export default function ReservationsIndex({
<ReservationStatus
status={reservation.status}
/>
<div>
{cancellableStatuses.includes(
reservation.status,
) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
openCancellation(reservation)
}
>
<Ban /> Cancelar
</Button>
)}
</div>
</div>
))}
</div>
@@ -88,6 +144,94 @@ export default function ReservationsIndex({
</Button>
</div>
)}
{reservations.last_page > 1 && (
<nav className="flex justify-center gap-2">
{reservations.links.map((link, index) => (
<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>
)}
<Dialog
open={selected !== null}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent>
<form onSubmit={submitCancellation}>
<DialogHeader>
<DialogTitle>Cancelar requisição</DialogTitle>
<DialogDescription>
{selected?.reference}: indique o motivo. O
cancelamento liberta imediatamente os
equipamentos reservados.
</DialogDescription>
</DialogHeader>
<div className="my-5">
<Label htmlFor="cancellation-reason">
Motivo do cancelamento
</Label>
<textarea
id="cancellation-reason"
value={cancelForm.data.reason}
onChange={(event) =>
cancelForm.setData(
'reason',
event.target.value,
)
}
rows={4}
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"
autoFocus
/>
<InputError
message={cancelForm.errors.reason}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setSelected(null)}
>
Manter requisição
</Button>
<Button
type="submit"
variant="destructive"
disabled={cancelForm.processing}
>
<Ban />
{cancelForm.processing
? 'A cancelar…'
: 'Confirmar cancelamento'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main>
</>
);