feat: add transactional approval workflow

This commit is contained in:
2026-08-04 16:36:15 +01:00
parent e667d05bc1
commit c2136bc250
18 changed files with 620 additions and 20 deletions

View File

@@ -3,6 +3,7 @@ import {
CalendarDays,
CalendarRange,
LayoutGrid,
ListChecks,
PackageSearch,
PlusCircle,
ServerCog,
@@ -53,16 +54,26 @@ const footerNavItems: NavItem[] = [
export function AppSidebar() {
const { can } = usePage().props;
const visibleNavItems = can.administer
const roleNavItems = can.approveReservations
? [
...mainNavItems,
{
title: 'Aprovações',
href: '/aprovacoes',
icon: ListChecks,
},
]
: mainNavItems;
const visibleNavItems = can.administer
? [
...roleNavItems,
{
title: 'Integração Snipe-IT',
href: '/administracao/snipe-it',
icon: ServerCog,
},
]
: mainNavItems;
: roleNavItems;
return (
<Sidebar collapsible="icon" variant="inset">

View File

@@ -0,0 +1,204 @@
import { Head, router, usePage } from '@inertiajs/react';
import { Check, CircleHelp, 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 { Paginated, Reservation } from '@/types';
type ApprovalReservation = Reservation & {
requester: {
name: string;
email: string;
department: string | null;
};
};
export default function ApprovalsIndex({
reservations,
filters,
}: {
reservations: Paginated<ApprovalReservation>;
filters: { search?: string };
}) {
const [search, setSearch] = useState(filters.search ?? '');
const [comments, setComments] = useState<Record<number, string>>({});
const { errors } = usePage().props;
function submitSearch(event: FormEvent) {
event.preventDefault();
router.get('/aprovacoes', { search: search || undefined });
}
function decide(reservation: ApprovalReservation, action: string) {
router.post(
`/aprovacoes/${reservation.uuid}/${action}`,
{ 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="flex gap-2">
<div className="relative max-w-md flex-1">
<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>
<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>
<p className="mt-1 font-medium">
{reservation.items
.map((item) => item.asset.name)
.join(', ')}
</p>
</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>
)}
</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>
</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))}`;
}
ApprovalsIndex.layout = {
breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }],
};

View File

@@ -18,6 +18,7 @@ declare module '@inertiajs/core' {
};
can: {
administer: boolean;
approveReservations: boolean;
};
sidebarOpen: boolean;
[key: string]: unknown;