@@ -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
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user