Files
reservas/resources/js/pages/operations/index.tsx

621 lines
32 KiB
TypeScript

import { Head, router, usePage } from '@inertiajs/react';
import {
AlertTriangle,
CheckCircle2,
ClipboardCheck,
PackageCheck,
RotateCcw,
ScanLine,
} 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
type HandoverRecord = {
id: number;
type: 'checkout' | 'checkin';
condition: string | null;
notes: string | null;
created_at: string;
operator: { name: string };
};
type OperationReservation = {
id: number;
uuid: string;
reference: string;
purpose: string;
usage_location: string;
starts_at: string;
ends_at: string;
status: string;
requester: {
name: string;
email: string;
department: string | null;
snipe_user_id: number | null;
};
items: {
id: number;
status: string;
asset: {
snipe_asset_id: number;
name: string;
asset_tag: string;
status_name: string | null;
location_name: string | null;
};
handover_records: HandoverRecord[];
}[];
};
type FormState = {
snipeUserId: string;
condition: string;
notes: string;
};
export default function OperationsIndex({
reservations,
operationallyConfigured,
}: {
reservations: OperationReservation[];
operationallyConfigured: boolean;
}) {
const { errors } = usePage().props;
const [forms, setForms] = useState<Record<number, FormState>>({});
const [incidents, setIncidents] = useState<
Record<number, { notes: string; blockedUntil: string }>
>({});
const [search, setSearch] = useState('');
const normalizedSearch = search.trim().toLocaleLowerCase('pt-PT');
const visibleReservations = reservations.filter((reservation) => {
if (!normalizedSearch) {
return true;
}
return [
reservation.reference,
reservation.requester.name,
reservation.requester.email,
...reservation.items.flatMap((item) => [
item.asset.asset_tag,
item.asset.snipe_asset_id.toString(),
]),
].some((value) =>
value.toLocaleLowerCase('pt-PT').includes(normalizedSearch),
);
});
function formFor(reservation: OperationReservation): FormState {
return (
forms[reservation.id] ?? {
snipeUserId:
reservation.requester.snipe_user_id?.toString() ?? '',
condition: 'Sem danos visíveis',
notes: '',
}
);
}
function updateForm(
reservation: OperationReservation,
key: keyof FormState,
value: string,
) {
setForms((current) => ({
...current,
[reservation.id]: { ...formFor(reservation), [key]: value },
}));
}
function post(
reservation: OperationReservation,
action: string,
data = {},
) {
router.post(`/operacoes/${reservation.uuid}/${action}`, data, {
preserveScroll: true,
});
}
return (
<>
<Head title="Operações" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Área do operador"
title="Entregas e devoluções"
description="Fila diária com registo simultâneo no portal e no Snipe-IT. As ocorrências ficam associadas ao operador e ao equipamento."
/>
<label className="relative block">
<ScanLine className="absolute top-2.5 left-3 size-5 text-muted-foreground" />
<Input
autoFocus
className="h-10 pl-11"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Ler QR/etiqueta ou pesquisar referência, nome e email…"
/>
<span className="mt-1 block text-xs text-muted-foreground">
Compatível com leitores QR/ código de barras que
funcionam como teclado; também aceita o ID Snipe-IT.
</span>
</label>
{!operationallyConfigured && (
<div className="flex gap-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<div>
<p className="font-semibold">
Movimentos externos indisponíveis
</p>
<p>
Configure SNIPE_IT_OPERATIONAL_TOKEN para ativar
entregas e devoluções. A preparação continua
disponível.
</p>
</div>
</div>
)}
<InputError message={errors.operation} />
<section className="space-y-4">
{visibleReservations.length === 0 && (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
<CheckCircle2 className="size-9 text-emerald-600" />
<p className="font-medium">
{search
? 'Nenhuma operação corresponde à leitura ou pesquisa.'
: 'Não há operações pendentes para hoje.'}
</p>
</CardContent>
</Card>
)}
{visibleReservations.map((reservation) => {
const form = formFor(reservation);
const canReturn =
[
'delivered',
'in_use',
'overdue',
'incident',
].includes(reservation.status) &&
reservation.items.some(
(item) => item.status === 'delivered',
);
const canDeliver = reservation.items.some(
(item) => item.status === 'ready',
);
const canComplete =
reservation.status === 'returned' ||
(reservation.status === 'incident' &&
reservation.items.every((item) =>
['returned', 'cancelled'].includes(
item.status,
),
));
return (
<Card
key={reservation.id}
className="gap-4 shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle className="font-mono text-base">
{reservation.reference}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{reservation.requester.name} ·{' '}
{reservation.usage_location}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatPeriod(
reservation.starts_at,
reservation.ends_at,
)}
</p>
</div>
<ReservationStatus
status={reservation.status}
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2">
{reservation.items
.filter(
(item) =>
item.status !== 'cancelled',
)
.map((item) => (
<div
key={item.id}
className="rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{
item.asset
.name
}
</p>
<p className="font-mono text-xs text-muted-foreground">
{
item.asset
.asset_tag
}{' '}
·{' '}
{item.asset
.location_name ??
'Sem localização'}
</p>
</div>
<span className="rounded bg-muted px-2 py-0.5 text-xs">
{item.status}
</span>
</div>
{item.handover_records
.length > 0 && (
<div className="mt-2 border-t pt-2 text-xs text-muted-foreground">
{item.handover_records.map(
(record) => (
<p
key={
record.id
}
>
{record.type ===
'checkout'
? 'Entregue'
: 'Devolvido'}{' '}
por{' '}
{
record
.operator
.name
}{' '}
·{' '}
{
record.condition
}
</p>
),
)}
</div>
)}
{[
'ready',
'delivered',
'in_use',
'overdue',
'returned',
].includes(
reservation.status,
) && (
<IncidentForm
itemId={item.id}
value={
incidents[
item.id
] ?? {
notes: '',
blockedUntil:
'',
}
}
onChange={(value) =>
setIncidents(
(
current,
) => ({
...current,
[item.id]:
value,
}),
)
}
onSubmit={() => {
const incident =
incidents[
item.id
] ?? {
notes: '',
blockedUntil:
'',
};
router.post(
`/operacoes/${reservation.uuid}/itens/${item.id}/incidente`,
{
notes: incident.notes,
blocked_until:
incident.blockedUntil ||
null,
},
{
preserveScroll: true,
},
);
}}
/>
)}
</div>
))}
</div>
{reservation.status === 'approved' && (
<Button
onClick={() =>
post(reservation, 'preparar')
}
>
<ClipboardCheck /> Preparar
equipamentos
</Button>
)}
{canDeliver && (
<div className="grid gap-3 rounded-xl bg-muted/50 p-4 md:grid-cols-3">
<label className="space-y-1 text-xs font-medium">
ID do destinatário no Snipe-IT
<Input
type="number"
min={1}
value={form.snipeUserId}
onChange={(event) =>
updateForm(
reservation,
'snipeUserId',
event.target.value,
)
}
/>
</label>
<label className="space-y-1 text-xs font-medium">
Estado à saída
<Input
value={form.condition}
onChange={(event) =>
updateForm(
reservation,
'condition',
event.target.value,
)
}
/>
</label>
<label className="space-y-1 text-xs font-medium">
Observações
<Input
value={form.notes}
onChange={(event) =>
updateForm(
reservation,
'notes',
event.target.value,
)
}
/>
</label>
<div className="flex flex-wrap gap-2 md:col-span-3">
<Button
disabled={
!operationallyConfigured ||
!form.snipeUserId
}
onClick={() =>
post(
reservation,
'entregar',
{
snipe_user_id:
Number(
form.snipeUserId,
),
condition:
form.condition,
notes:
form.notes ||
null,
},
)
}
>
<PackageCheck /> Registar
entrega
</Button>
<Button
variant="outline"
disabled={
form.notes.trim()
.length < 3
}
onClick={() =>
post(
reservation,
'falta',
{
notes: form.notes,
},
)
}
>
Registar falta
</Button>
</div>
</div>
)}
{canReturn && (
<div className="grid gap-3 rounded-xl bg-muted/50 p-4 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium">
Estado à entrada
<Input
value={form.condition}
onChange={(event) =>
updateForm(
reservation,
'condition',
event.target.value,
)
}
/>
</label>
<label className="space-y-1 text-xs font-medium">
Observações
<Input
value={form.notes}
onChange={(event) =>
updateForm(
reservation,
'notes',
event.target.value,
)
}
/>
</label>
<div className="flex flex-wrap gap-2 md:col-span-2">
<Button
disabled={
!operationallyConfigured
}
onClick={() =>
post(
reservation,
'devolver',
{
condition:
form.condition,
notes:
form.notes ||
null,
},
)
}
>
<RotateCcw /> Registar
devolução
</Button>
{[
'delivered',
'in_use',
].includes(
reservation.status,
) && (
<Button
variant="outline"
disabled={
form.notes.trim()
.length < 3
}
onClick={() =>
post(
reservation,
'atraso',
{
notes: form.notes,
},
)
}
>
Marcar atraso
</Button>
)}
</div>
</div>
)}
{canComplete && (
<Button
onClick={() =>
post(reservation, 'concluir')
}
>
<CheckCircle2 /> Concluir processo
</Button>
)}
</CardContent>
</Card>
);
})}
</section>
</main>
</>
);
}
function IncidentForm({
value,
onChange,
onSubmit,
}: {
itemId: number;
value: { notes: string; blockedUntil: string };
onChange: (value: { notes: string; blockedUntil: string }) => void;
onSubmit: () => void;
}) {
return (
<details className="mt-3 border-t pt-2">
<summary className="cursor-pointer text-xs font-medium text-amber-700">
Registar incidente
</summary>
<div className="mt-2 space-y-2">
<Input
placeholder="Descrição do incidente"
value={value.notes}
onChange={(event) =>
onChange({ ...value, notes: event.target.value })
}
/>
<label className="block text-xs text-muted-foreground">
Bloquear equipamento até (opcional)
<Input
className="mt-1"
type="datetime-local"
value={value.blockedUntil}
onChange={(event) =>
onChange({
...value,
blockedUntil: event.target.value,
})
}
/>
</label>
<Button
size="sm"
variant="outline"
disabled={value.notes.trim().length < 5}
onClick={onSubmit}
>
<AlertTriangle /> Registar
</Button>
</div>
</details>
);
}
function formatPeriod(startsAt: string, endsAt: string) {
const formatter = new Intl.DateTimeFormat('pt-PT', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
return `${formatter.format(new Date(startsAt))} — ${formatter.format(new Date(endsAt))}`;
}
OperationsIndex.layout = {
breadcrumbs: [{ title: 'Operações', href: '/operacoes' }],
};