first commit
Some checks failed
tests / ci (push) Has been cancelled

This commit is contained in:
2026-10-27 11:01:11 +00:00
parent 9b8b21c853
commit fb04d71a8d
29 changed files with 9733 additions and 326 deletions

View File

@@ -2,10 +2,13 @@ import { Head, Link, router } from '@inertiajs/react';
import {
Boxes,
CalendarClock,
FilterX,
MapPin,
PackageCheck,
Search,
SlidersHorizontal,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { useState } from 'react';
@@ -67,6 +70,22 @@ export default function AssetsIndex({
);
}
function clearFilters() {
setSearch('');
setCategory('all');
setLocation('all');
setStartsAt('');
setEndsAt('');
router.get('/equipamentos', {}, { preserveState: true });
}
const hasActiveFilters =
search ||
category !== 'all' ||
location !== 'all' ||
startsAt ||
endsAt;
return (
<>
<Head title="Equipamentos" />
@@ -77,81 +96,121 @@ export default function AssetsIndex({
description="Consulte os equipamentos requisitáveis registados no Snipe-IT e inicie uma requisição."
/>
<form
onSubmit={submit}
className="grid gap-3 rounded-xl border bg-card p-4 lg:grid-cols-6"
>
<div className="relative lg:col-span-2">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Nome, modelo ou etiqueta…"
className="pl-9"
/>
</div>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas as categorias
</SelectItem>
{categories.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
type="datetime-local"
aria-label="Início do período"
value={startsAt}
onChange={(event) =>
setStartsAt(event.target.value)
}
className="pl-9"
required={Boolean(endsAt)}
/>
</div>
<Input
type="datetime-local"
aria-label="Fim do período"
value={endsAt}
onChange={(event) => setEndsAt(event.target.value)}
required={Boolean(startsAt)}
/>
<Select value={location} onValueChange={setLocation}>
<SelectTrigger>
<SelectValue placeholder="Localização" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas as localizações
</SelectItem>
{locations.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit" variant="secondary">
<SlidersHorizontal /> Filtrar
</Button>
</form>
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardContent className="p-4">
<form onSubmit={submit} className="space-y-4">
<div className="relative">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) =>
setSearch(event.target.value)
}
placeholder="Pesquisar equipamentos por nome, modelo ou etiqueta…"
className="h-12 pl-10 text-base"
/>
</div>
<details className="group">
<summary className="flex cursor-pointer items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
<SlidersHorizontal className="size-4" />
Mais filtros
</summary>
<div className="mt-4 grid gap-4 lg:grid-cols-5">
<Select
value={category}
onValueChange={setCategory}
>
<SelectTrigger>
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas as categorias
</SelectItem>
{categories.map((item) => (
<SelectItem
key={item}
value={item}
>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
type="datetime-local"
aria-label="Início do período"
value={startsAt}
onChange={(event) =>
setStartsAt(event.target.value)
}
className="pl-9"
required={Boolean(endsAt)}
/>
</div>
<Input
type="datetime-local"
aria-label="Fim do período"
value={endsAt}
onChange={(event) =>
setEndsAt(event.target.value)
}
required={Boolean(startsAt)}
/>
<Select
value={location}
onValueChange={setLocation}
>
<SelectTrigger>
<SelectValue placeholder="Localização" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas as localizações
</SelectItem>
{locations.map((item) => (
<SelectItem
key={item}
value={item}
>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
type="submit"
className="flex-1"
>
<SlidersHorizontal /> Filtrar
</Button>
{hasActiveFilters && (
<Button
type="button"
variant="outline"
onClick={clearFilters}
>
<FilterX />
</Button>
)}
</div>
</div>
</details>
</form>
</CardContent>
</Card>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
<span className="font-medium">
{assets.total} equipamento
{assets.total === 1 ? '' : 's'}
</span>
{lastSyncedAt && (
<span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-emerald-500" />
Sincronizado em{' '}
{new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'short',
@@ -182,36 +241,82 @@ export default function AssetsIndex({
<p className="mt-1 text-sm text-muted-foreground">
Altere os filtros e volte a tentar.
</p>
{hasActiveFilters && (
<Button
variant="link"
onClick={clearFilters}
className="mt-2"
>
Limpar filtros
</Button>
)}
</div>
)}
{assets.last_page > 1 && (
<nav className="flex justify-center gap-2">
{assets.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,
}}
/>
{assets.links.map((link, index) => {
const isFirst = index === 0;
const isLast = index === assets.links.length - 1;
if (isFirst || isLast) {
const icon = isFirst ? (
<ChevronLeft className="size-4" />
) : (
<span
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
)}
</Button>
))}
<ChevronRight className="size-4" />
);
return (
<Button
key={index}
asChild={Boolean(link.url)}
disabled={!link.url}
variant="outline"
size="sm"
className="min-w-[32px]"
>
{link.url ? (
<Link
href={link.url}
preserveScroll
>
{icon}
</Link>
) : (
<span>{icon}</span>
)}
</Button>
);
}
return (
<Button
key={index}
asChild={Boolean(link.url)}
disabled={!link.url}
variant={
link.active ? 'default' : 'outline'
}
size="sm"
>
{link.url ? (
<Link
href={link.url}
preserveScroll
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
) : (
<span
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
)}
</Button>
);
})}
</nav>
)}
</main>
@@ -238,35 +343,44 @@ function AssetCard({
}
return (
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md dark:border-slate-800">
<div className="flex h-36 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition-all hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg dark:border-slate-800">
<div className="relative flex h-40 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
{asset.image_url ? (
<img
src={asset.image_url}
alt=""
className="h-full w-full object-cover"
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<PackageCheck className="size-12 text-slate-300 transition group-hover:text-primary/40" />
<PackageCheck className="size-14 text-slate-300 transition group-hover:text-primary/50" />
)}
<Badge
variant="outline"
className="absolute top-3 right-3 bg-white/90 backdrop-blur-sm dark:bg-slate-900/90"
>
{periodSelected ? 'Livre no período' : 'Requisitável'}
</Badge>
</div>
<CardContent className="space-y-4 pb-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold">{asset.name}</h2>
<p className="mt-1 text-sm text-muted-foreground">
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold">{asset.name}</h2>
<p className="mt-1 truncate text-sm text-muted-foreground">
{asset.model_name}
</p>
</div>
<Badge variant="outline" className="text-emerald-700">
{periodSelected ? 'Livre no período' : 'Requisitável'}
</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
<span className="font-mono">{asset.asset_tag}</span>
{asset.category_name && <span>{asset.category_name}</span>}
<span className="rounded bg-muted px-2 py-0.5 font-mono">
{asset.asset_tag}
</span>
{asset.category_name && (
<span className="rounded bg-muted px-2 py-0.5">
{asset.category_name}
</span>
)}
{asset.location_name && (
<span className="flex items-center gap-1">
<span className="flex items-center gap-1 rounded bg-muted px-2 py-0.5">
<MapPin className="size-3" />
{asset.location_name}
</span>

View File

@@ -1,9 +1,11 @@
import { Form, Head } from '@inertiajs/react';
import { Laptop2, ShieldCheck } from 'lucide-react';
import InputError from '@/components/input-error';
import PasskeyVerify from '@/components/passkey-verify';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -26,6 +28,18 @@ export default function Login({
<>
<Head title="Entrar" />
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 dark:from-primary/10 dark:to-primary/5">
<Laptop2 className="size-8 text-primary" />
</div>
<h1 className="text-2xl font-bold tracking-tight">
Portal de Reservas
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Sistema de gestão de equipamentos da escola
</p>
</div>
<PasskeyVerify
label="Entrar com chave de acesso"
loadingLabel="A autenticar..."
@@ -55,7 +69,7 @@ export default function Login({
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
@@ -67,6 +81,7 @@ export default function Login({
tabIndex={1}
autoComplete="email"
placeholder="nome@escola.pt"
className="h-11"
/>
<InputError message={errors.email} />
</div>
@@ -93,6 +108,7 @@ export default function Login({
tabIndex={2}
autoComplete="current-password"
placeholder="Palavra-passe"
className="h-11"
/>
<InputError message={errors.password} />
</div>
@@ -103,12 +119,14 @@ export default function Login({
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Manter sessão</Label>
<Label htmlFor="remember" className="text-sm">
Manter sessão
</Label>
</div>
<Button
type="submit"
className="mt-4 w-full"
className="mt-2 h-11 w-full"
tabIndex={4}
disabled={processing}
data-test="login-button"
@@ -118,10 +136,21 @@ export default function Login({
</Button>
</div>
<p className="text-center text-sm text-muted-foreground">
O acesso é atribuído pela escola. Contacte os
Serviços de Informática se ainda não tiver conta.
</p>
<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">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="text-sm">
<p className="font-medium text-foreground">
Acesso seguro
</p>
<p className="mt-1 text-muted-foreground">
O acesso é atribuído pela escola.
Contacte os Serviços de Informática se
ainda não tiver conta.
</p>
</div>
</CardContent>
</Card>
</>
)}
</Form>

View File

@@ -6,6 +6,7 @@ import {
Clock3,
PackageCheck,
Plus,
TrendingUp,
} from 'lucide-react';
import { PageHeading } from '@/components/page-heading';
import { ReservationStatus } from '@/components/reservation-status';
@@ -206,23 +207,42 @@ function SummaryCard({
tone: 'amber' | 'emerald' | 'blue';
}) {
const colors = {
amber: 'bg-amber-50 text-amber-700',
emerald: 'bg-emerald-50 text-emerald-700',
blue: 'bg-blue-50 text-blue-700',
amber: {
bg: 'bg-amber-50 dark:bg-amber-950/30',
text: 'text-amber-700 dark:text-amber-400',
icon: 'bg-amber-100 dark:bg-amber-900/50',
},
emerald: {
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
text: 'text-emerald-700 dark:text-emerald-400',
icon: 'bg-emerald-100 dark:bg-emerald-900/50',
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
text: 'text-blue-700 dark:text-blue-400',
icon: 'bg-blue-100 dark:bg-blue-900/50',
},
};
return (
<Card className="gap-3 border-slate-200 py-5 shadow-none dark:border-slate-800">
<CardContent className="flex items-center gap-4">
<Card className="group overflow-hidden border-slate-200 py-0 shadow-none transition-all hover:shadow-md dark:border-slate-800">
<CardContent className="flex items-center gap-4 p-5">
<span
className={`flex size-11 items-center justify-center rounded-xl ${colors[tone]}`}
className={`flex size-12 items-center justify-center rounded-2xl ${colors[tone].icon} transition-transform group-hover:scale-110`}
>
<Icon className="size-5" />
<Icon className={`size-6 ${colors[tone].text}`} />
</span>
<div>
<p className="text-2xl font-semibold">{value}</p>
<p className="text-sm text-muted-foreground">{label}</p>
<div className="flex-1">
<p className="text-3xl font-bold tracking-tight">{value}</p>
<p className="mt-1 text-sm text-muted-foreground">
{label}
</p>
</div>
{value > 0 && (
<TrendingUp
className={`size-4 ${colors[tone].text} opacity-50`}
/>
)}
</CardContent>
</Card>
);

View File

@@ -1,23 +1,48 @@
import { Head, Link } from '@inertiajs/react';
import { ArrowLeft, ArrowRight, CalendarDays, Plus } from 'lucide-react';
import { Head, Link, router } from '@inertiajs/react';
import { ArrowLeft, ArrowRight, CalendarDays, Clock, Filter, Package, Plus, User, X } from 'lucide-react';
import { useState } from 'react';
import { PageHeading } from '@/components/page-heading';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { Reservation } from '@/types';
const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
const statusOptions = [
{ value: '', label: 'Todos os estados' },
{ value: 'pending', label: 'Pendente' },
{ value: 'confirmed', label: 'Confirmada ou em curso' },
{ value: 'closed', label: 'Fechada sem utilização' },
];
export default function ReservationCalendar({
month,
reservations,
scope = 'personal',
status = '',
}: {
month: string;
reservations: (Reservation & {
requester?: { name: string; department: string | null };
})[];
scope?: 'personal' | 'global';
status?: string;
}) {
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const [year, monthNumber] = month.split('-').map(Number);
const monthStart = new Date(year, monthNumber - 1, 1);
const firstWeekday = (monthStart.getDay() + 6) % 7;
@@ -41,6 +66,59 @@ export default function ReservationCalendar({
? '/calendario-global'
: '/requisicoes/calendario';
function handleStatusChange(value: string) {
router.get(calendarUrl, { month, status: value || undefined }, {
preserveState: true,
preserveScroll: true,
});
}
function handleDayClick(day: Date) {
setSelectedDay(day);
}
function formatDate(date: Date): string {
return date.toLocaleDateString('pt-PT', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
function formatTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleTimeString('pt-PT', {
hour: '2-digit',
minute: '2-digit',
});
}
function getStatusLabel(status: string): string {
if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) {
return 'Fechada sem utilização';
}
if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) {
return 'Confirmada ou em curso';
}
return 'Pendente';
}
function getStatusColor(status: string): string {
if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) {
return 'bg-slate-400';
}
if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) {
return 'bg-emerald-500';
}
return 'bg-amber-500';
}
return (
<>
<Head title={global ? 'Calendário global' : 'O meu calendário'} />
@@ -67,6 +145,32 @@ export default function ReservationCalendar({
) : undefined
}
/>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="size-4 text-muted-foreground" />
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Filtrar por estado" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{status && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatusChange('')}
>
<X className="size-4" />
</Button>
)}
</div>
</div>
<section className="overflow-hidden rounded-xl border bg-card">
<header className="flex items-center justify-between border-b p-4">
@@ -118,10 +222,12 @@ export default function ReservationCalendar({
return (
<div
key={day.toISOString()}
onClick={() => entries.length > 0 && handleDayClick(day)}
className={cn(
'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2',
'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2 cursor-pointer',
!isCurrentMonth &&
'bg-slate-50/70 text-muted-foreground dark:bg-slate-950/40',
entries.length > 0 && 'hover:bg-slate-50 dark:hover:bg-slate-900/50',
)}
>
<span
@@ -184,6 +290,92 @@ export default function ReservationCalendar({
requisições neste mês.
</div>
)}
<Dialog open={selectedDay !== null} onOpenChange={() => setSelectedDay(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Requisições do dia</DialogTitle>
</DialogHeader>
{selectedDay && (() => {
const dayStart = new Date(
selectedDay.getFullYear(),
selectedDay.getMonth(),
selectedDay.getDate(),
);
const dayEnd = new Date(
selectedDay.getFullYear(),
selectedDay.getMonth(),
selectedDay.getDate() + 1,
);
const dayEntries = reservations.filter(
(reservation) =>
new Date(reservation.starts_at) < dayEnd &&
new Date(reservation.ends_at) > dayStart,
);
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{formatDate(selectedDay)}
</p>
{dayEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Não existem requisições neste dia.
</p>
) : (
<div className="space-y-3">
{dayEntries.map((reservation) => (
<div
key={reservation.id}
className="rounded-lg border bg-slate-50 p-4 dark:bg-slate-900"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">
{reservation.reference}
</span>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium text-white',
getStatusColor(reservation.status),
)}
>
{getStatusLabel(reservation.status)}
</span>
</div>
{global && reservation.requester && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<User className="size-3" />
<span>{reservation.requester.name}</span>
{reservation.requester.department && (
<span>· {reservation.requester.department}</span>
)}
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
<span>
{formatTime(reservation.starts_at)} - {formatTime(reservation.ends_at)}
</span>
</div>
<div className="flex items-start gap-1.5 text-xs text-muted-foreground">
<Package className="size-3 mt-0.5" />
<span className="line-clamp-2">
{reservation.items.map((item) => item.asset.name).join(', ')}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})()}
</DialogContent>
</Dialog>
</main>
</>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,18 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Ban, CalendarDays, MessageSquareText, Plus } from 'lucide-react';
import { Head, Link, useForm, router } from '@inertiajs/react';
import {
Ban,
CalendarDays,
MessageSquareText,
Plus,
Repeat,
Filter,
X,
} 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -14,12 +23,39 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Paginated, Reservation } from '@/types';
const statusOptions = [
{ value: '', label: 'Todos os estados' },
{ value: 'pending_approval', label: 'Pendente de aprovação' },
{ value: 'awaiting_information', label: 'A aguardar esclarecimentos' },
{ value: 'approved', label: 'Aprovada' },
{ value: 'ready', label: 'Pronta para entrega' },
{ value: 'delivered', label: 'Entregue' },
{ value: 'in_use', label: 'Em utilização' },
{ value: 'returned', label: 'Devolvida' },
{ value: 'completed', label: 'Concluída' },
{ value: 'rejected', label: 'Rejeitada' },
{ value: 'cancelled', label: 'Cancelada' },
{ value: 'expired', label: 'Expirada' },
{ value: 'no_show', label: 'Falta' },
{ value: 'overdue', label: 'Atrasada' },
{ value: 'incident', label: 'Incidente' },
];
export default function ReservationsIndex({
reservations,
status = '',
}: {
reservations: Paginated<Reservation>;
status?: string;
}) {
const [selected, setSelected] = useState<Reservation | null>(null);
const [clarification, setClarification] = useState<Reservation | null>(
@@ -39,6 +75,13 @@ export default function ReservationsIndex({
'ready',
];
function handleStatusChange(value: string) {
router.get('/requisicoes', { status: value || undefined }, {
preserveState: true,
preserveScroll: true,
});
}
function openCancellation(reservation: Reservation) {
cancelForm.reset();
cancelForm.clearErrors();
@@ -93,6 +136,32 @@ export default function ReservationsIndex({
</Button>
}
/>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="size-4 text-muted-foreground" />
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Filtrar por estado" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{status && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatusChange('')}
>
<X className="size-4" />
</Button>
)}
</div>
</div>
{reservations.data.length ? (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[150px_1fr_220px_150px_110px] gap-4 border-b bg-slate-50 px-5 py-3 text-xs font-medium text-muted-foreground uppercase md:grid dark:bg-slate-900">
@@ -107,9 +176,20 @@ export default function ReservationsIndex({
key={reservation.id}
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_150px_110px] md:items-center"
>
<span className="font-mono text-sm font-medium">
{reservation.reference}
</span>
<div className="flex flex-col gap-1">
<span className="font-mono text-sm font-medium">
{reservation.reference}
</span>
{reservation.recurring_group_id && (
<Badge
variant="outline"
className="w-fit gap-1 border-primary/30 text-[10px] text-primary"
>
<Repeat className="size-2.5" />{' '}
Série semanal
</Badge>
)}
</div>
<div className="flex flex-col items-start gap-1">
{reservation.status ===
'awaiting_information' && (