feat: add transactional approval workflow
This commit is contained in:
@@ -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">
|
||||
|
||||
204
resources/js/pages/approvals/index.tsx
Normal file
204
resources/js/pages/approvals/index.tsx
Normal 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' }],
|
||||
};
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -18,6 +18,7 @@ declare module '@inertiajs/core' {
|
||||
};
|
||||
can: {
|
||||
administer: boolean;
|
||||
approveReservations: boolean;
|
||||
};
|
||||
sidebarOpen: boolean;
|
||||
[key: string]: unknown;
|
||||
|
||||
Reference in New Issue
Block a user