feat: complete approval workflow
This commit is contained in:
@@ -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' }],
|
||||
};
|
||||
|
||||
123
resources/js/pages/notifications/index.tsx
Normal file
123
resources/js/pages/notifications/index.tsx
Normal 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' }],
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user