621 lines
32 KiB
TypeScript
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' }],
|
|
};
|