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

@@ -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>
</>
);