feat: complete approval workflow

This commit is contained in:
2026-08-04 16:51:51 +01:00
parent c2136bc250
commit 809eab9d4a
26 changed files with 1156 additions and 41 deletions

View File

@@ -1,5 +1,5 @@
import { Head, router, usePage } from '@inertiajs/react';
import { Check, CircleHelp, Search, X } from 'lucide-react';
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';
@@ -8,7 +8,7 @@ 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';
import type { Asset, Paginated, Reservation } from '@/types';
type ApprovalReservation = Reservation & {
requester: {
@@ -16,22 +16,49 @@ type ApprovalReservation = Reservation & {
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 };
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 });
router.get('/aprovacoes', {
search: search || undefined,
category: category || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
});
}
function decide(reservation: ApprovalReservation, action: string) {
@@ -42,6 +69,17 @@ export default function ApprovalsIndex({
);
}
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" />
@@ -51,8 +89,11 @@ export default function ApprovalsIndex({
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">
<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}
@@ -61,6 +102,30 @@ export default function ApprovalsIndex({
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>
@@ -90,11 +155,131 @@ export default function ApprovalsIndex({
<p className="text-xs text-muted-foreground uppercase">
Equipamentos
</p>
<p className="mt-1 font-medium">
<div className="mt-1 space-y-2">
{reservation.items
.map((item) => item.asset.name)
.join(', ')}
</p>
.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">
@@ -175,6 +360,38 @@ export default function ApprovalsIndex({
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>
))}
@@ -185,6 +402,35 @@ export default function ApprovalsIndex({
</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>
</>
);
@@ -199,6 +445,18 @@ function formatPeriod(reservation: Reservation): string {
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' }],
};

View File

@@ -0,0 +1,123 @@
import { Head, Link, router } from '@inertiajs/react';
import { Bell, CheckCheck } from 'lucide-react';
import { PageHeading } from '@/components/page-heading';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Paginated } from '@/types';
type PortalNotification = {
id: string;
data: {
message: string;
url?: string;
reference?: string;
};
read_at: string | null;
created_at: string;
};
export default function NotificationsIndex({
notifications,
}: {
notifications: Paginated<PortalNotification>;
}) {
function openNotification(notification: PortalNotification) {
router.post(`/notificacoes/${notification.id}/ler`);
}
return (
<>
<Head title="Notificações" />
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Caixa interna"
title="Notificações"
description="Atualizações sobre requisições, decisões e pedidos de esclarecimento."
action={
<Button
variant="outline"
onClick={() =>
router.post('/notificacoes/ler-todas')
}
>
<CheckCheck /> Marcar todas como lidas
</Button>
}
/>
<section className="overflow-hidden rounded-xl border bg-card">
{notifications.data.map((notification) => (
<button
type="button"
key={notification.id}
onClick={() => openNotification(notification)}
className={cn(
'flex w-full gap-4 border-b p-5 text-left last:border-0 hover:bg-slate-50 dark:hover:bg-slate-900',
notification.read_at === null && 'bg-primary/5',
)}
>
<span className="mt-0.5 rounded-full bg-primary/10 p-2 text-primary">
<Bell className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{notification.data.message}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(
new Date(notification.created_at),
)}
</p>
</div>
{notification.read_at === null && (
<span className="mt-2 size-2 rounded-full bg-primary" />
)}
</button>
))}
{notifications.data.length === 0 && (
<div className="py-16 text-center text-sm text-muted-foreground">
Não existem notificações.
</div>
)}
</section>
{notifications.last_page > 1 && (
<nav className="flex justify-center gap-2">
{notifications.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>
</>
);
}
NotificationsIndex.layout = {
breadcrumbs: [{ title: 'Notificações', href: '/notificacoes' }],
};

View File

@@ -1,5 +1,5 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Ban, CalendarDays, Plus } from 'lucide-react';
import { Ban, CalendarDays, MessageSquareText, Plus } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading';
@@ -22,7 +22,11 @@ export default function ReservationsIndex({
reservations: Paginated<Reservation>;
}) {
const [selected, setSelected] = useState<Reservation | null>(null);
const [clarification, setClarification] = useState<Reservation | null>(
null,
);
const cancelForm = useForm({ reason: '' });
const clarificationForm = useForm({ response: '' });
const format = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
@@ -54,6 +58,25 @@ export default function ReservationsIndex({
});
}
function openClarification(reservation: Reservation) {
clarificationForm.reset();
clarificationForm.clearErrors();
setClarification(reservation);
}
function submitClarification(event: React.FormEvent) {
event.preventDefault();
if (!clarification) {
return;
}
clarificationForm.post(`/requisicoes/${clarification.uuid}/responder`, {
preserveScroll: true,
onSuccess: () => setClarification(null),
});
}
return (
<>
<Head title="As minhas requisições" />
@@ -87,7 +110,20 @@ export default function ReservationsIndex({
<span className="font-mono text-sm font-medium">
{reservation.reference}
</span>
<div>
<div className="flex flex-col items-start gap-1">
{reservation.status ===
'awaiting_information' && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
openClarification(reservation)
}
>
<MessageSquareText /> Responder
</Button>
)}
<p className="text-sm font-medium">
{reservation.items
.map((item) => item.asset.name)
@@ -232,6 +268,61 @@ export default function ReservationsIndex({
</form>
</DialogContent>
</Dialog>
<Dialog
open={clarification !== null}
onOpenChange={(open) => !open && setClarification(null)}
>
<DialogContent>
<form onSubmit={submitClarification}>
<DialogHeader>
<DialogTitle>
Prestar esclarecimentos
</DialogTitle>
<DialogDescription>
{clarification?.decision_reason ??
'O aprovador pediu informação adicional para poder decidir.'}
</DialogDescription>
</DialogHeader>
<div className="my-5">
<Label htmlFor="clarification-response">
Resposta
</Label>
<textarea
id="clarification-response"
value={clarificationForm.data.response}
onChange={(event) =>
clarificationForm.setData(
'response',
event.target.value,
)
}
rows={5}
className="mt-2 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoFocus
/>
<InputError
message={clarificationForm.errors.response}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setClarification(null)}
>
Voltar
</Button>
<Button
type="submit"
disabled={clarificationForm.processing}
>
<MessageSquareText /> Enviar resposta
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main>
</>
);