463 lines
26 KiB
TypeScript
463 lines
26 KiB
TypeScript
import { Head, Link, router, usePage } from '@inertiajs/react';
|
|
import { Check, CircleHelp, RefreshCw, Search, X } from 'lucide-react';
|
|
import type { FormEvent } from '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';
|
|
import type { Asset, Paginated, Reservation } from '@/types';
|
|
|
|
type ApprovalReservation = Reservation & {
|
|
requester: {
|
|
name: string;
|
|
email: string;
|
|
department: string | null;
|
|
};
|
|
approval_actions: {
|
|
id: number;
|
|
action: string;
|
|
comment: string | null;
|
|
created_at: string;
|
|
actor: { name: string };
|
|
}[];
|
|
};
|
|
|
|
export default function ApprovalsIndex({
|
|
reservations,
|
|
filters,
|
|
replacementAssets,
|
|
categories,
|
|
}: {
|
|
reservations: Paginated<ApprovalReservation>;
|
|
filters: {
|
|
search?: string;
|
|
category?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
};
|
|
replacementAssets: Asset[];
|
|
categories: string[];
|
|
}) {
|
|
const [search, setSearch] = useState(filters.search ?? '');
|
|
const [category, setCategory] = useState(filters.category ?? '');
|
|
const [dateFrom, setDateFrom] = useState(filters.date_from ?? '');
|
|
const [dateTo, setDateTo] = useState(filters.date_to ?? '');
|
|
const [comments, setComments] = useState<Record<number, string>>({});
|
|
const [replacements, setReplacements] = useState<Record<number, string>>(
|
|
{},
|
|
);
|
|
const { errors } = usePage().props;
|
|
|
|
function submitSearch(event: FormEvent) {
|
|
event.preventDefault();
|
|
router.get('/aprovacoes', {
|
|
search: search || undefined,
|
|
category: category || undefined,
|
|
date_from: dateFrom || undefined,
|
|
date_to: dateTo || undefined,
|
|
});
|
|
}
|
|
|
|
function decide(reservation: ApprovalReservation, action: string) {
|
|
router.post(
|
|
`/aprovacoes/${reservation.uuid}/${action}`,
|
|
{ comment: comments[reservation.id] ?? '' },
|
|
{ preserveScroll: true },
|
|
);
|
|
}
|
|
|
|
function substitute(reservation: ApprovalReservation, itemId: number) {
|
|
router.post(
|
|
`/aprovacoes/${reservation.uuid}/itens/${itemId}/substituir`,
|
|
{
|
|
replacement_asset_id: Number(replacements[itemId]),
|
|
comment: comments[reservation.id] ?? '',
|
|
},
|
|
{ preserveScroll: true },
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title="Aprovaçõ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 aprovador"
|
|
title="Requisições por decidir"
|
|
description="Reveja o período, a finalidade e os equipamentos antes de tomar uma decisão."
|
|
/>
|
|
<form
|
|
onSubmit={submitSearch}
|
|
className="grid gap-2 rounded-xl border bg-card p-4 md:grid-cols-[1fr_200px_170px_170px_auto]"
|
|
>
|
|
<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="Referência ou requisitante…"
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={category}
|
|
onChange={(event) => setCategory(event.target.value)}
|
|
className="rounded-md border bg-transparent px-3 text-sm"
|
|
>
|
|
<option value="">Todas as categorias</option>
|
|
{categories.map((item) => (
|
|
<option key={item} value={item}>
|
|
{item}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Input
|
|
type="date"
|
|
aria-label="Data inicial"
|
|
value={dateFrom}
|
|
onChange={(event) => setDateFrom(event.target.value)}
|
|
/>
|
|
<Input
|
|
type="date"
|
|
aria-label="Data final"
|
|
value={dateTo}
|
|
onChange={(event) => setDateTo(event.target.value)}
|
|
/>
|
|
<Button type="submit" variant="secondary">
|
|
Pesquisar
|
|
</Button>
|
|
</form>
|
|
|
|
<section className="space-y-4">
|
|
{reservations.data.map((reservation) => (
|
|
<Card key={reservation.id} className="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.requester.department ??
|
|
reservation.requester.email}
|
|
</p>
|
|
</div>
|
|
<ReservationStatus
|
|
status={reservation.status}
|
|
/>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 text-sm sm:grid-cols-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground uppercase">
|
|
Equipamentos
|
|
</p>
|
|
<div className="mt-1 space-y-2">
|
|
{reservation.items
|
|
.filter(
|
|
(item) =>
|
|
item.status !==
|
|
'cancelled',
|
|
)
|
|
.map((item) => {
|
|
const candidates =
|
|
replacementAssets.filter(
|
|
(asset) =>
|
|
asset.id !==
|
|
item.asset
|
|
.id &&
|
|
(item.asset
|
|
.category_id !=
|
|
null
|
|
? asset.category_id ===
|
|
item.asset
|
|
.category_id
|
|
: asset.category_name ===
|
|
item.asset
|
|
.category_name),
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className="space-y-1"
|
|
>
|
|
<p className="font-medium">
|
|
{
|
|
item.asset
|
|
.name
|
|
}{' '}
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
{
|
|
item
|
|
.asset
|
|
.asset_tag
|
|
}
|
|
</span>
|
|
</p>
|
|
{reservation.status ===
|
|
'pending_approval' &&
|
|
candidates.length >
|
|
0 && (
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={
|
|
replacements[
|
|
item
|
|
.id
|
|
] ??
|
|
''
|
|
}
|
|
onChange={(
|
|
event,
|
|
) =>
|
|
setReplacements(
|
|
(
|
|
current,
|
|
) => ({
|
|
...current,
|
|
[item.id]:
|
|
event
|
|
.target
|
|
.value,
|
|
}),
|
|
)
|
|
}
|
|
className="min-w-0 flex-1 rounded-md border bg-transparent px-2 py-1 text-xs"
|
|
>
|
|
<option value="">
|
|
Substituir
|
|
por…
|
|
</option>
|
|
{candidates.map(
|
|
(
|
|
asset,
|
|
) => (
|
|
<option
|
|
key={
|
|
asset.id
|
|
}
|
|
value={
|
|
asset.id
|
|
}
|
|
>
|
|
{
|
|
asset.name
|
|
}{' '}
|
|
—{' '}
|
|
{
|
|
asset.asset_tag
|
|
}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={
|
|
!replacements[
|
|
item
|
|
.id
|
|
]
|
|
}
|
|
onClick={() =>
|
|
substitute(
|
|
reservation,
|
|
item.id,
|
|
)
|
|
}
|
|
>
|
|
<RefreshCw />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground uppercase">
|
|
Período
|
|
</p>
|
|
<p className="mt-1 font-medium">
|
|
{formatPeriod(reservation)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p className="rounded-lg bg-slate-50 p-3 text-sm dark:bg-slate-900">
|
|
{reservation.purpose}
|
|
</p>
|
|
{reservation.status === 'pending_approval' ? (
|
|
<>
|
|
<textarea
|
|
value={
|
|
comments[reservation.id] ?? ''
|
|
}
|
|
onChange={(event) =>
|
|
setComments((current) => ({
|
|
...current,
|
|
[reservation.id]:
|
|
event.target.value,
|
|
}))
|
|
}
|
|
rows={2}
|
|
placeholder="Comentário opcional na aprovação; obrigatório para rejeitar ou pedir esclarecimentos…"
|
|
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<InputError
|
|
message={
|
|
errors.comment ??
|
|
errors.decision
|
|
}
|
|
/>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
decide(
|
|
reservation,
|
|
'pedir-esclarecimentos',
|
|
)
|
|
}
|
|
>
|
|
<CircleHelp /> Pedir
|
|
esclarecimentos
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() =>
|
|
decide(
|
|
reservation,
|
|
'rejeitar',
|
|
)
|
|
}
|
|
>
|
|
<X /> Rejeitar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() =>
|
|
decide(
|
|
reservation,
|
|
'aprovar',
|
|
)
|
|
}
|
|
>
|
|
<Check /> Aprovar
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-amber-700">
|
|
A aguardar resposta do requisitante.
|
|
</p>
|
|
)}
|
|
{reservation.approval_actions.length > 0 && (
|
|
<details className="rounded-lg border p-3 text-sm">
|
|
<summary className="cursor-pointer font-medium">
|
|
Histórico de aprovação (
|
|
{
|
|
reservation.approval_actions
|
|
.length
|
|
}
|
|
)
|
|
</summary>
|
|
<div className="mt-3 space-y-3 border-t pt-3">
|
|
{reservation.approval_actions.map(
|
|
(action) => (
|
|
<div key={action.id}>
|
|
<p className="font-medium">
|
|
{actionLabel(
|
|
action.action,
|
|
)}{' '}
|
|
·{' '}
|
|
{action.actor.name}
|
|
</p>
|
|
{action.comment && (
|
|
<p className="mt-1 text-muted-foreground">
|
|
{action.comment}
|
|
</p>
|
|
)}
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{reservations.data.length === 0 && (
|
|
<div className="rounded-xl border border-dashed py-16 text-center text-sm text-muted-foreground">
|
|
Não existem requisições pendentes para os filtros
|
|
selecionados.
|
|
</div>
|
|
)}
|
|
</section>
|
|
{reservations.last_page > 1 && (
|
|
<nav className="flex justify-center gap-2">
|
|
{reservations.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,
|
|
}}
|
|
/>
|
|
) : (
|
|
<span
|
|
dangerouslySetInnerHTML={{
|
|
__html: link.label,
|
|
}}
|
|
/>
|
|
)}
|
|
</Button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function formatPeriod(reservation: Reservation): string {
|
|
const format = new Intl.DateTimeFormat('pt-PT', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
});
|
|
|
|
return `${format.format(new Date(reservation.starts_at))} — ${format.format(new Date(reservation.ends_at))}`;
|
|
}
|
|
|
|
function actionLabel(action: string): string {
|
|
return (
|
|
{
|
|
approved: 'Aprovada',
|
|
rejected: 'Rejeitada',
|
|
information_requested: 'Esclarecimentos pedidos',
|
|
information_provided: 'Esclarecimentos recebidos',
|
|
asset_substituted: 'Equipamento substituído',
|
|
}[action] ?? action
|
|
);
|
|
}
|
|
|
|
ApprovalsIndex.layout = {
|
|
breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }],
|
|
};
|