feat: complete equipment handover workflow
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
LayoutGrid,
|
||||
ListChecks,
|
||||
PackageSearch,
|
||||
PackageCheck,
|
||||
PlusCircle,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
@@ -71,16 +72,26 @@ export function AppSidebar() {
|
||||
},
|
||||
]
|
||||
: userNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
const operationalNavItems = can.operateInventory
|
||||
? [
|
||||
...roleNavItems,
|
||||
{
|
||||
title: 'Operações',
|
||||
href: '/operacoes',
|
||||
icon: PackageCheck,
|
||||
},
|
||||
]
|
||||
: roleNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
? [
|
||||
...operationalNavItems,
|
||||
{
|
||||
title: 'Integração Snipe-IT',
|
||||
href: '/administracao/snipe-it',
|
||||
icon: ServerCog,
|
||||
},
|
||||
]
|
||||
: roleNavItems;
|
||||
: operationalNavItems;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
|
||||
@@ -33,6 +33,11 @@ const statuses: Record<string, { label: string; className: string }> = {
|
||||
label: 'Devolução atrasada',
|
||||
className: 'bg-red-100 text-red-800',
|
||||
},
|
||||
no_show: {
|
||||
label: 'Não compareceu',
|
||||
className: 'bg-slate-100 text-slate-700',
|
||||
},
|
||||
incident: { label: 'Incidente', className: 'bg-red-100 text-red-800' },
|
||||
};
|
||||
|
||||
export function ReservationStatus({ status }: { status: string }) {
|
||||
|
||||
620
resources/js/pages/operations/index.tsx
Normal file
620
resources/js/pages/operations/index.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
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' }],
|
||||
};
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -19,6 +19,7 @@ declare module '@inertiajs/core' {
|
||||
can: {
|
||||
administer: boolean;
|
||||
approveReservations: boolean;
|
||||
operateInventory: boolean;
|
||||
};
|
||||
unreadNotifications: number;
|
||||
sidebarOpen: boolean;
|
||||
|
||||
Reference in New Issue
Block a user