419 lines
20 KiB
TypeScript
419 lines
20 KiB
TypeScript
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;
|
|
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));
|
|
const global = scope === 'global';
|
|
const calendarUrl = global
|
|
? '/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'} />
|
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
|
<PageHeading
|
|
eyebrow={
|
|
global
|
|
? 'Planeamento institucional'
|
|
: 'Planeamento pessoal'
|
|
}
|
|
title={global ? 'Calendário global' : 'O meu calendário'}
|
|
description={
|
|
global
|
|
? 'Visão consolidada de todas as requisições, requisitantes e equipamentos.'
|
|
: 'Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos.'
|
|
}
|
|
action={
|
|
!global ? (
|
|
<Button asChild>
|
|
<Link href="/requisicoes/nova">
|
|
<Plus /> Nova requisição
|
|
</Link>
|
|
</Button>
|
|
) : 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">
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link
|
|
href={`${calendarUrl}?month=${previousMonth}`}
|
|
>
|
|
<ArrowLeft /> Anterior
|
|
</Link>
|
|
</Button>
|
|
<h2 className="text-lg font-semibold capitalize">
|
|
{title}
|
|
</h2>
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href={`${calendarUrl}?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()}
|
|
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 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
|
|
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}
|
|
{global &&
|
|
reservation.requester
|
|
? ` · ${reservation.requester.name}`
|
|
: ''}
|
|
</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>
|
|
)}
|
|
|
|
<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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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: 'Calendário', href: '/requisicoes/calendario' }],
|
|
};
|