690
resources/js/components/recurring-conflict-modal.tsx
Normal file
690
resources/js/components/recurring-conflict-modal.tsx
Normal file
@@ -0,0 +1,690 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { Asset, OccurrenceCheck } from '@/types';
|
||||
|
||||
type RecurringConflictModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isLoading: boolean;
|
||||
checkResult: {
|
||||
occurrences: OccurrenceCheck[];
|
||||
total_occurrences: number;
|
||||
conflicts_count: number;
|
||||
} | null;
|
||||
initialAssetIds: number[];
|
||||
isSubmitting: boolean;
|
||||
onConfirmSubmit: (
|
||||
occurrencesToSubmit: Array<{
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
asset_ids: number[];
|
||||
}>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function RecurringConflictModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
isLoading,
|
||||
checkResult,
|
||||
initialAssetIds,
|
||||
isSubmitting,
|
||||
onConfirmSubmit,
|
||||
}: RecurringConflictModalProps) {
|
||||
const [omittedIndexes, setOmittedIndexes] = useState<number[]>([]);
|
||||
// Key format: `${occurrenceIndex}_${originalAssetId}` -> replacement Asset
|
||||
const [replacements, setReplacements] = useState<Record<string, Asset>>({});
|
||||
const [activeSelectorKey, setActiveSelectorKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const occurrences = checkResult?.occurrences ?? [];
|
||||
|
||||
function toggleOmit(index: number) {
|
||||
setOmittedIndexes((prev) =>
|
||||
prev.includes(index)
|
||||
? prev.filter((i) => i !== index)
|
||||
: [...prev, index],
|
||||
);
|
||||
}
|
||||
|
||||
function toggleReplacementSelector(key: string) {
|
||||
setActiveSelectorKey((prev) => (prev === key ? null : key));
|
||||
}
|
||||
|
||||
function selectReplacement(
|
||||
occIndex: number,
|
||||
originalAssetId: number,
|
||||
replacement: Asset,
|
||||
) {
|
||||
setReplacements((prev) => ({
|
||||
...prev,
|
||||
[`${occIndex}_${originalAssetId}`]: replacement,
|
||||
}));
|
||||
setActiveSelectorKey(null);
|
||||
}
|
||||
|
||||
function removeReplacement(occIndex: number, originalAssetId: number) {
|
||||
setReplacements((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${occIndex}_${originalAssetId}`];
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const activeOccurrences = occurrences.filter(
|
||||
(_, idx) => !omittedIndexes.includes(idx),
|
||||
);
|
||||
const omittedCount = occurrences.length - activeOccurrences.length;
|
||||
|
||||
// Check if there are unresolved conflicts in active occurrences
|
||||
const hasUnresolvedConflicts = occurrences.some((check, idx) => {
|
||||
if (omittedIndexes.includes(idx) || !check.has_conflict) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return check.conflicted_assets.some(
|
||||
(ca) => !replacements[`${idx}_${ca.id}`],
|
||||
);
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
!isLoading &&
|
||||
activeOccurrences.length > 0 &&
|
||||
!hasUnresolvedConflicts &&
|
||||
!isSubmitting;
|
||||
|
||||
function handleConfirm() {
|
||||
const payload = occurrences
|
||||
.map((check, index) => ({ check, index }))
|
||||
.filter(({ index }) => !omittedIndexes.includes(index))
|
||||
.map(({ check, index }) => {
|
||||
const finalAssetIds = initialAssetIds.map((origId) => {
|
||||
const replacement = replacements[`${index}_${origId}`];
|
||||
|
||||
return replacement ? replacement.id : origId;
|
||||
});
|
||||
|
||||
return {
|
||||
starts_at: check.starts_at,
|
||||
ends_at: check.ends_at,
|
||||
asset_ids: finalAssetIds,
|
||||
};
|
||||
});
|
||||
|
||||
onConfirmSubmit(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="border-b p-6 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl font-semibold">
|
||||
<Calendar className="size-5 text-primary" />
|
||||
Confirmação de Série Semanal
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Análise de disponibilidade de todas as semanas da sua
|
||||
requisição recorrente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4 py-16 text-center">
|
||||
<RefreshCw className="mx-auto size-8 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
A verificar disponibilidade semana a semana…
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A cruzar horários com o inventário e requisições
|
||||
existentes.
|
||||
</p>
|
||||
</div>
|
||||
) : checkResult ? (
|
||||
<>
|
||||
{/* Summary banner */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border bg-card p-3 shadow-xs">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Semanas totais
|
||||
</span>
|
||||
<p className="text-2xl font-bold">
|
||||
{checkResult.total_occurrences}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 shadow-xs">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
A requisitar
|
||||
</span>
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{activeOccurrences.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3 shadow-xs">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Conflitos detetados
|
||||
</span>
|
||||
<p
|
||||
className={`text-2xl font-bold ${
|
||||
checkResult.conflicts_count > 0
|
||||
? 'text-amber-600 dark:text-amber-500'
|
||||
: 'text-emerald-600 dark:text-emerald-500'
|
||||
}`}
|
||||
>
|
||||
{checkResult.conflicts_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informative alert */}
|
||||
{checkResult.conflicts_count > 0 ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">
|
||||
Existem conflitos de horário com
|
||||
outros pedidos!
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed opacity-90">
|
||||
Para cada dia assinalado,
|
||||
escolha se prefere{' '}
|
||||
<strong>
|
||||
apagar a reserva desse dia
|
||||
</strong>{' '}
|
||||
(excluindo-o da série) ou{' '}
|
||||
<strong>
|
||||
trocar o material
|
||||
</strong>{' '}
|
||||
por outro equipamento
|
||||
disponível.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm text-emerald-900 dark:text-emerald-200">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||
<p className="font-semibold">
|
||||
Todos os equipamentos estão
|
||||
disponíveis para todas as semanas da
|
||||
série!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Occurrence Cards List */}
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((check, index) => {
|
||||
const isOmitted =
|
||||
omittedIndexes.includes(index);
|
||||
const hasConflicts = check.has_conflict;
|
||||
const allConflictsResolved =
|
||||
hasConflicts &&
|
||||
check.conflicted_assets.every(
|
||||
(ca) =>
|
||||
replacements[
|
||||
`${index}_${ca.id}`
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={check.index}
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
isOmitted
|
||||
? 'border-dashed bg-slate-50 opacity-50 dark:bg-slate-900/50'
|
||||
: hasConflicts &&
|
||||
!allConflictsResolved
|
||||
? 'border-amber-400 bg-amber-50/40 shadow-xs dark:border-amber-700/60 dark:bg-amber-950/20'
|
||||
: 'border-slate-200 bg-card dark:border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-semibold text-muted-foreground">
|
||||
Semana {index + 1}
|
||||
</span>
|
||||
<p
|
||||
className={`text-sm font-semibold ${
|
||||
isOmitted
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
check.date_formatted
|
||||
}
|
||||
</p>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
{
|
||||
check.time_formatted
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{isOmitted ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-dashed text-muted-foreground"
|
||||
>
|
||||
Dia apagado /
|
||||
ignorado
|
||||
</Badge>
|
||||
) : allConflictsResolved ? (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-600 text-white"
|
||||
>
|
||||
Conflito
|
||||
resolvido com
|
||||
troca
|
||||
</Badge>
|
||||
) : hasConflicts ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="bg-amber-600 text-white"
|
||||
>
|
||||
Conflito de
|
||||
material
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||||
>
|
||||
Equipamento
|
||||
disponível
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isOmitted ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleOmit(
|
||||
index,
|
||||
)
|
||||
}
|
||||
className="h-8 gap-1"
|
||||
>
|
||||
<Undo2 className="size-3.5" />
|
||||
Restaurar este dia
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleOmit(
|
||||
index,
|
||||
)
|
||||
}
|
||||
className="h-8 gap-1 text-rose-600 hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-950/40"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Apagar este dia
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details of items & conflicts */}
|
||||
{!isOmitted && (
|
||||
<div className="mt-3 space-y-2 border-t pt-3 text-xs">
|
||||
{check.conflicted_assets.map(
|
||||
(cAsset) => {
|
||||
const key = `${index}_${cAsset.id}`;
|
||||
const replacement =
|
||||
replacements[
|
||||
key
|
||||
];
|
||||
const isSelecting =
|
||||
activeSelectorKey ===
|
||||
key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
cAsset.id
|
||||
}
|
||||
className="space-y-2 rounded-lg border border-amber-300/80 bg-amber-50/60 p-3 dark:border-amber-800/80 dark:bg-amber-950/30"
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<span className="font-semibold text-amber-900 dark:text-amber-200">
|
||||
Material
|
||||
em
|
||||
conflito:{' '}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{
|
||||
cAsset.name
|
||||
}{' '}
|
||||
(
|
||||
{
|
||||
cAsset.asset_tag
|
||||
}
|
||||
|
||||
)
|
||||
</span>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
Já
|
||||
se
|
||||
encontra
|
||||
reservado
|
||||
por
|
||||
outro
|
||||
utilizador
|
||||
para
|
||||
este
|
||||
horário.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{replacement ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeReplacement(
|
||||
index,
|
||||
cAsset.id,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Undo2 className="mr-1 size-3" />{' '}
|
||||
Reverter
|
||||
troca
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleReplacementSelector(
|
||||
key,
|
||||
)
|
||||
}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
{isSelecting
|
||||
? 'Fechar lista'
|
||||
: 'Trocar material'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replacement indicator */}
|
||||
{replacement && (
|
||||
<div className="flex items-center justify-between rounded-md border border-emerald-400 bg-emerald-50 p-2 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||
<span>
|
||||
Substituído
|
||||
por:{' '}
|
||||
<strong>
|
||||
{
|
||||
replacement.name
|
||||
}
|
||||
</strong>{' '}
|
||||
(
|
||||
{
|
||||
replacement.asset_tag
|
||||
}
|
||||
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interactive Alternatives Selector */}
|
||||
{isSelecting &&
|
||||
!replacement && (
|
||||
<div className="mt-2 space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-foreground">
|
||||
Equipamentos
|
||||
disponíveis
|
||||
para
|
||||
este
|
||||
dia
|
||||
(
|
||||
{
|
||||
cAsset
|
||||
.alternatives
|
||||
.length
|
||||
}
|
||||
):
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleReplacementSelector(
|
||||
key,
|
||||
)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cAsset
|
||||
.alternatives
|
||||
.length ===
|
||||
0 ? (
|
||||
<p className="py-1 text-muted-foreground italic">
|
||||
Não
|
||||
foram
|
||||
encontrados
|
||||
equipamentos
|
||||
alternativos
|
||||
livres
|
||||
para
|
||||
este
|
||||
horário.
|
||||
Sugerimos
|
||||
apagar
|
||||
a
|
||||
reserva
|
||||
deste
|
||||
dia.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid max-h-40 gap-1.5 overflow-y-auto">
|
||||
{cAsset.alternatives.map(
|
||||
(
|
||||
alt,
|
||||
) => (
|
||||
<div
|
||||
key={
|
||||
alt.id
|
||||
}
|
||||
className="flex cursor-pointer items-center justify-between rounded border p-2 transition-colors hover:bg-muted/50"
|
||||
onClick={() =>
|
||||
selectReplacement(
|
||||
index,
|
||||
cAsset.id,
|
||||
alt,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
{
|
||||
alt.name
|
||||
}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-[10px] text-muted-foreground">
|
||||
{
|
||||
alt.asset_tag
|
||||
}
|
||||
</span>
|
||||
{alt.category_name && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||
·{' '}
|
||||
{
|
||||
alt.category_name
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-6 text-xs"
|
||||
onClick={(
|
||||
e,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
selectReplacement(
|
||||
index,
|
||||
cAsset.id,
|
||||
alt,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Escolher
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{/* Available assets for this occurrence */}
|
||||
{check.available_assets
|
||||
.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Package className="size-3.5 shrink-0 text-emerald-600" />
|
||||
<span>
|
||||
Disponíveis:
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{check.available_assets
|
||||
.map(
|
||||
(a) =>
|
||||
`${a.name} (${a.asset_tag})`,
|
||||
)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col justify-between gap-3 border-t bg-slate-50 p-4 sm:flex-row sm:items-center dark:bg-slate-900/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{activeOccurrences.length > 0 ? (
|
||||
<span>
|
||||
A submeter{' '}
|
||||
<strong>{activeOccurrences.length}</strong>{' '}
|
||||
requisições
|
||||
{omittedCount > 0
|
||||
? ` (${omittedCount} dias ignorados)`
|
||||
: ''}
|
||||
.
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-rose-600">
|
||||
Todos os dias foram apagados. Restaure pelo
|
||||
menos um dia para submeter.
|
||||
</span>
|
||||
)}
|
||||
{hasUnresolvedConflicts && (
|
||||
<span className="mt-0.5 block font-medium text-amber-600 dark:text-amber-400">
|
||||
⚠ Resolva os conflitos pendentes (trocando o
|
||||
material ou apagando o dia).
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canSubmit}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
A submeter…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Confirmar e submeter série
|
||||
<ArrowRight className="size-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
216
resources/js/components/weekly-slot-selector.tsx
Normal file
216
resources/js/components/weekly-slot-selector.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { WeeklySlot } from '@/types';
|
||||
|
||||
interface WeeklySlotSelectorProps {
|
||||
weeklySlots: WeeklySlot[];
|
||||
onChange: (slots: WeeklySlot[]) => void;
|
||||
slotAvailability?: Map<string, boolean>;
|
||||
weekOffset?: number;
|
||||
}
|
||||
|
||||
const timeSlots = [
|
||||
{ start: '08:30', end: '09:30' },
|
||||
{ start: '09:40', end: '10:40' },
|
||||
{ start: '10:50', end: '11:50' },
|
||||
{ start: '11:50', end: '12:50' },
|
||||
{ start: '13:00', end: '14:00' },
|
||||
{ start: '14:00', end: '15:00' },
|
||||
{ start: '15:10', end: '16:10' },
|
||||
{ start: '16:20', end: '17:20' },
|
||||
{ start: '17:20', end: '18:20' },
|
||||
];
|
||||
|
||||
const weekDays = [
|
||||
{ value: 1, label: 'Seg' },
|
||||
{ value: 2, label: 'Ter' },
|
||||
{ value: 3, label: 'Qua' },
|
||||
{ value: 4, label: 'Qui' },
|
||||
{ value: 5, label: 'Sex' },
|
||||
];
|
||||
|
||||
export function WeeklySlotSelector({
|
||||
weeklySlots,
|
||||
onChange,
|
||||
slotAvailability,
|
||||
weekOffset = 0,
|
||||
}: WeeklySlotSelectorProps) {
|
||||
// Calculate dates for the week
|
||||
const getWeekDates = (offset: number) => {
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // Monday
|
||||
|
||||
const dates: Record<number, Date> = {};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const date = new Date(startOfWeek);
|
||||
date.setDate(startOfWeek.getDate() + i);
|
||||
dates[i + 1] = date;
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
const weekDates = getWeekDates(weekOffset);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const isSlotSelected = (day: number, start: string, end: string) => {
|
||||
return weeklySlots.some(
|
||||
(slot) =>
|
||||
slot.day_of_week === day &&
|
||||
slot.start_time === start &&
|
||||
slot.end_time === end,
|
||||
);
|
||||
};
|
||||
|
||||
const isSlotAvailable = (day: number, start: string, end: string) => {
|
||||
if (!slotAvailability) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = `${day}-${start}-${end}`;
|
||||
|
||||
return slotAvailability.get(key) ?? true;
|
||||
};
|
||||
|
||||
const toggleSlot = (day: number, start: string, end: string) => {
|
||||
if (!isSlotAvailable(day, start, end)) {
|
||||
return; // Don't allow selecting unavailable slots
|
||||
}
|
||||
|
||||
const existingIndex = weeklySlots.findIndex(
|
||||
(slot) =>
|
||||
slot.day_of_week === day &&
|
||||
slot.start_time === start &&
|
||||
slot.end_time === end,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove slot
|
||||
const newSlots = weeklySlots.filter((_, i) => i !== existingIndex);
|
||||
onChange(newSlots);
|
||||
} else {
|
||||
// Add slot
|
||||
onChange([
|
||||
...weeklySlots,
|
||||
{ day_of_week: day, start_time: start, end_time: end },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Selecione os horários</Label>
|
||||
{weeklySlots.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Limpar tudo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[500px]">
|
||||
{/* Header row with day names */}
|
||||
<div className="grid grid-cols-6 gap-2 mb-2">
|
||||
<div className="text-xs font-medium text-muted-foreground" />
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day.value}
|
||||
className="text-center text-xs font-medium text-foreground"
|
||||
>
|
||||
{day.label} <span className="text-muted-foreground">{formatDate(weekDates[day.value])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time slots rows */}
|
||||
{timeSlots.map((time) => (
|
||||
<div key={time.start} className="grid grid-cols-6 gap-2 mb-2">
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
{time.start}-{time.end}
|
||||
</div>
|
||||
{weekDays.map((day) => {
|
||||
const selected = isSlotSelected(
|
||||
day.value,
|
||||
time.start,
|
||||
time.end,
|
||||
);
|
||||
const available = isSlotAvailable(
|
||||
day.value,
|
||||
time.start,
|
||||
time.end,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${day.value}-${time.start}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleSlot(
|
||||
day.value,
|
||||
time.start,
|
||||
time.end,
|
||||
)
|
||||
}
|
||||
disabled={!available}
|
||||
title={
|
||||
available
|
||||
? `${time.start} - ${time.end}`
|
||||
: 'Sem equipamentos disponíveis'
|
||||
}
|
||||
className={`
|
||||
relative rounded-lg border-2 p-2 text-center text-xs transition-all
|
||||
${
|
||||
selected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: available
|
||||
? 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
||||
: 'border-slate-200 bg-slate-100 opacity-50 cursor-not-allowed dark:border-slate-700 dark:bg-slate-800/30'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{time.start}-{time.end}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="absolute right-1 top-1">
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
{!available && (
|
||||
<div className="absolute -top-1 -right-1 size-3 rounded-full bg-destructive" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{weeklySlots.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-4 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
Selecione pelo menos um horário para continuar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -29,9 +29,31 @@ export type Reservation = {
|
||||
ends_at: string;
|
||||
status: string;
|
||||
decision_reason?: string | null;
|
||||
recurring_group_id?: string | null;
|
||||
recurrence_pattern?: string | null;
|
||||
items: ReservationItem[];
|
||||
};
|
||||
|
||||
export type ConflictedAsset = {
|
||||
id: number;
|
||||
name: string;
|
||||
asset_tag: string;
|
||||
model_name: string | null;
|
||||
category_name: string | null;
|
||||
alternatives: Asset[];
|
||||
};
|
||||
|
||||
export type OccurrenceCheck = {
|
||||
index: number;
|
||||
date_formatted: string;
|
||||
time_formatted: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
has_conflict: boolean;
|
||||
conflicted_assets: ConflictedAsset[];
|
||||
available_assets: Asset[];
|
||||
};
|
||||
|
||||
export type PaginationLink = {
|
||||
url: string | null;
|
||||
label: string;
|
||||
@@ -47,3 +69,9 @@ export type Paginated<T> = {
|
||||
total: number;
|
||||
links: PaginationLink[];
|
||||
};
|
||||
|
||||
export type WeeklySlot = {
|
||||
day_of_week: number; // 0-6 (Sunday-Saturday)
|
||||
start_time: string; // HH:mm format
|
||||
end_time: string; // HH:mm format
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user