feat: establish reservation portal foundation

This commit is contained in:
2026-08-04 15:24:08 +01:00
commit c96264ae96
226 changed files with 32232 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
import { Head, Link, router } from '@inertiajs/react';
import {
Boxes,
MapPin,
PackageCheck,
Search,
SlidersHorizontal,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { PageHeading } from '@/components/page-heading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Asset, Paginated } from '@/types';
type Props = {
assets: Paginated<Asset>;
filters: { search?: string; category?: string; location?: string };
categories: string[];
locations: string[];
lastSyncedAt: string | null;
};
export default function AssetsIndex({
assets,
filters,
categories,
locations,
lastSyncedAt,
}: Props) {
const [search, setSearch] = useState(filters.search ?? '');
const [category, setCategory] = useState(filters.category ?? 'all');
const [location, setLocation] = useState(filters.location ?? 'all');
function submit(event: FormEvent) {
event.preventDefault();
router.get(
'/equipamentos',
{
search: search || undefined,
category: category === 'all' ? undefined : category,
location: location === 'all' ? undefined : location,
},
{ preserveState: true },
);
}
return (
<>
<Head title="Equipamentos" />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Inventário sincronizado"
title="Equipamentos disponíveis"
description="Consulte os equipamentos requisitáveis registados no Snipe-IT e inicie uma requisição."
/>
<form
onSubmit={submit}
className="grid gap-3 rounded-xl border bg-card p-4 md:grid-cols-[1fr_220px_220px_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="Nome, modelo ou etiqueta…"
className="pl-9"
/>
</div>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas as categorias
</SelectItem>
{categories.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={location} onValueChange={setLocation}>
<SelectTrigger>
<SelectValue placeholder="Localização" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas as localizações
</SelectItem>
{locations.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit" variant="secondary">
<SlidersHorizontal /> Filtrar
</Button>
</form>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{assets.total} equipamento
{assets.total === 1 ? '' : 's'}
</span>
{lastSyncedAt && (
<span>
Sincronizado em{' '}
{new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(lastSyncedAt))}
</span>
)}
</div>
{assets.data.length > 0 ? (
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{assets.data.map((asset) => (
<AssetCard key={asset.id} asset={asset} />
))}
</section>
) : (
<div className="rounded-xl border border-dashed py-16 text-center">
<Boxes className="mx-auto size-9 text-slate-300" />
<h2 className="mt-4 font-medium">
Nenhum equipamento encontrado
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Altere os filtros e volte a tentar.
</p>
</div>
)}
{assets.last_page > 1 && (
<nav className="flex justify-center gap-2">
{assets.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 AssetCard({ asset }: { asset: Asset }) {
return (
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md dark:border-slate-800">
<div className="flex h-36 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
{asset.image_url ? (
<img
src={asset.image_url}
alt=""
className="h-full w-full object-cover"
/>
) : (
<PackageCheck className="size-12 text-slate-300 transition group-hover:text-primary/40" />
)}
</div>
<CardContent className="space-y-4 pb-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold">{asset.name}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{asset.model_name}
</p>
</div>
<Badge variant="outline" className="text-emerald-700">
Disponível
</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
<span className="font-mono">{asset.asset_tag}</span>
{asset.category_name && <span>{asset.category_name}</span>}
{asset.location_name && (
<span className="flex items-center gap-1">
<MapPin className="size-3" />
{asset.location_name}
</span>
)}
</div>
<Button asChild className="w-full">
<Link href={`/requisicoes/nova?asset=${asset.id}`}>
Requisitar equipamento
</Link>
</Button>
</CardContent>
</Card>
);
}
AssetsIndex.layout = {
breadcrumbs: [{ title: 'Equipamentos', href: '/equipamentos' }],
};

View File

@@ -0,0 +1,66 @@
import { Form, Head } from '@inertiajs/react';
import {
index as confirmOptions,
store as confirmStore,
} from '@/actions/Laravel/Passkeys/Http/Controllers/PasskeyConfirmationController';
import InputError from '@/components/input-error';
import PasskeyVerify from '@/components/passkey-verify';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { store } from '@/routes/password/confirm';
export default function ConfirmPassword() {
return (
<>
<Head title="Confirm password" />
<PasskeyVerify
routes={{
options: confirmOptions(),
submit: confirmStore(),
}}
label="Confirm with passkey"
loadingLabel="Confirming..."
separator="Or confirm with password"
/>
<Form {...store.form()} resetOnSuccess={['password']}>
{({ processing, errors }) => (
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
placeholder="Password"
autoComplete="current-password"
autoFocus
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center">
<Button
className="w-full"
disabled={processing}
data-test="confirm-password-button"
>
{processing && <Spinner />}
Confirm password
</Button>
</div>
</div>
)}
</Form>
</>
);
}
ConfirmPassword.layout = {
title: 'Confirm password',
description:
'This is a secure area of the application. Please confirm your password before continuing.',
};

View File

@@ -0,0 +1,69 @@
// Components
import { Form, Head } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { login } from '@/routes';
import { email } from '@/routes/password';
export default function ForgotPassword({ status }: { status?: string }) {
return (
<>
<Head title="Forgot password" />
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
{status}
</div>
)}
<div className="space-y-6">
<Form {...email.form()}>
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
autoComplete="off"
autoFocus
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="my-6 flex items-center justify-start">
<Button
className="w-full"
disabled={processing}
data-test="email-password-reset-link-button"
>
{processing && (
<LoaderCircle className="h-4 w-4 animate-spin" />
)}
Email password reset link
</Button>
</div>
</>
)}
</Form>
<div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span>
<TextLink href={login()}>log in</TextLink>
</div>
</div>
</>
);
}
ForgotPassword.layout = {
title: 'Forgot password',
description: 'Enter your email to receive a password reset link',
};

View File

@@ -0,0 +1,114 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasskeyVerify from '@/components/passkey-verify';
import PasswordInput from '@/components/password-input';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
type Props = {
status?: string;
canResetPassword: boolean;
};
export default function Login({ status, canResetPassword }: Props) {
return (
<>
<Head title="Log in" />
<PasskeyVerify />
<Form
{...store.form()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
required
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
tabIndex={5}
>
Forgot your password?
</TextLink>
)}
</div>
<PasswordInput
id="password"
name="password"
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<Checkbox
id="remember"
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
</div>
<Button
type="submit"
className="mt-4 w-full"
tabIndex={4}
disabled={processing}
data-test="login-button"
>
{processing && <Spinner />}
Log in
</Button>
</div>
<p className="text-center text-sm text-muted-foreground">
O acesso é atribuído pela escola. Contacte os
Serviços de Informática se ainda não tiver conta.
</p>
</>
)}
</Form>
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
{status}
</div>
)}
</>
);
}
Login.layout = {
title: 'Entrar no Portal de Reservas',
description: 'Utilize o seu endereço institucional e palavra-passe.',
};

View File

@@ -0,0 +1,96 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { update } from '@/routes/password';
type Props = {
token: string;
email: string;
passwordRules: string;
};
export default function ResetPassword({ token, email, passwordRules }: Props) {
return (
<>
<Head title="Reset password" />
<Form
{...update.form()}
transform={(data) => ({ ...data, token, email })}
resetOnSuccess={['password', 'password_confirmation']}
>
{({ processing, errors }) => (
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autoComplete="email"
value={email}
className="mt-1 block w-full"
readOnly
/>
<InputError
message={errors.email}
className="mt-2"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"
placeholder="Confirm password"
passwordrules={passwordRules}
/>
<InputError
message={errors.password_confirmation}
className="mt-2"
/>
</div>
<Button
type="submit"
className="mt-4 w-full"
disabled={processing}
data-test="reset-password-button"
>
{processing && <Spinner />}
Reset password
</Button>
</div>
)}
</Form>
</>
);
}
ResetPassword.layout = {
title: 'Reset password',
description: 'Please enter your new password below',
};

View File

@@ -0,0 +1,133 @@
import { Form, Head, setLayoutProps } from '@inertiajs/react';
import { REGEXP_ONLY_DIGITS } from 'input-otp';
import { useMemo, useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
import { store } from '@/routes/two-factor/login';
export default function TwoFactorChallenge() {
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
const [code, setCode] = useState<string>('');
const authConfigContent = useMemo<{
title: string;
description: string;
toggleText: string;
}>(() => {
if (showRecoveryInput) {
return {
title: 'Recovery code',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
toggleText: 'login using an authentication code',
};
}
return {
title: 'Authentication code',
description:
'Enter the authentication code provided by your authenticator application.',
toggleText: 'login using a recovery code',
};
}, [showRecoveryInput]);
setLayoutProps({
title: authConfigContent.title,
description: authConfigContent.description,
});
const toggleRecoveryMode = (clearErrors: () => void): void => {
setShowRecoveryInput(!showRecoveryInput);
clearErrors();
setCode('');
};
return (
<>
<Head title="Two-factor authentication" />
<div className="space-y-6">
<Form
{...store.form()}
className="space-y-4"
resetOnError
resetOnSuccess={!showRecoveryInput}
>
{({ errors, processing, clearErrors }) => (
<>
{showRecoveryInput ? (
<>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
autoFocus={showRecoveryInput}
required
/>
<InputError
message={errors.recovery_code}
/>
</>
) : (
<div className="flex flex-col items-center justify-center space-y-3 text-center">
<div className="flex w-full items-center justify-center">
<InputOTP
name="code"
maxLength={OTP_MAX_LENGTH}
value={code}
onChange={(value) => setCode(value)}
disabled={processing}
pattern={REGEXP_ONLY_DIGITS}
autoFocus
>
<InputOTPGroup>
{Array.from(
{ length: OTP_MAX_LENGTH },
(_, index) => (
<InputOTPSlot
key={index}
index={index}
/>
),
)}
</InputOTPGroup>
</InputOTP>
</div>
<InputError message={errors.code} />
</div>
)}
<Button
type="submit"
className="w-full"
disabled={processing}
>
Continue
</Button>
<div className="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
onClick={() =>
toggleRecoveryMode(clearErrors)
}
>
{authConfigContent.toggleText}
</button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@@ -0,0 +1,46 @@
// Components
import { Form, Head } from '@inertiajs/react';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { logout } from '@/routes';
import { send } from '@/routes/verification';
export default function VerifyEmail({ status }: { status?: string }) {
return (
<>
<Head title="Email verification" />
{status === 'verification-link-sent' && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address
you provided during registration.
</div>
)}
<Form {...send.form()} className="space-y-6 text-center">
{({ processing }) => (
<>
<Button disabled={processing} variant="secondary">
{processing && <Spinner />}
Resend verification email
</Button>
<TextLink
href={logout()}
className="mx-auto block text-sm"
>
Log out
</TextLink>
</>
)}
</Form>
</>
);
}
VerifyEmail.layout = {
title: 'Email verification',
description:
'Please verify your email address by clicking on the link we just emailed to you.',
};

View File

@@ -0,0 +1,233 @@
import { Head, Link } from '@inertiajs/react';
import {
ArrowRight,
CalendarClock,
CheckCircle2,
Clock3,
PackageCheck,
Plus,
} from 'lucide-react';
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 type { Reservation } from '@/types';
type Props = {
summary: { pending: number; approved: number; inUse: number };
nextReservation: Reservation | null;
recentReservations: Reservation[];
};
const dateTime = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
});
export default function Dashboard({
summary,
nextReservation,
recentReservations,
}: Props) {
return (
<>
<Head title="Visão geral" />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 p-5 sm:p-8">
<PageHeading
eyebrow="Área pessoal"
title="Bom dia. O que precisa de reservar?"
description="Consulte a disponibilidade dos equipamentos e acompanhe as suas requisições num só lugar."
action={
<Button asChild className="shadow-sm">
<Link href="/requisicoes/nova">
<Plus /> Nova requisição
</Link>
</Button>
}
/>
<section className="grid gap-4 sm:grid-cols-3">
<SummaryCard
icon={Clock3}
label="A aguardar aprovação"
value={summary.pending}
tone="amber"
/>
<SummaryCard
icon={CheckCircle2}
label="Aprovadas"
value={summary.approved}
tone="emerald"
/>
<SummaryCard
icon={PackageCheck}
label="Em utilização"
value={summary.inUse}
tone="blue"
/>
</section>
<section className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardHeader className="flex-row items-center justify-between">
<div>
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
Próxima
</p>
<CardTitle className="mt-2">
Próxima requisição
</CardTitle>
</div>
<CalendarClock className="size-5 text-primary" />
</CardHeader>
<CardContent>
{nextReservation ? (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
<span className="font-mono text-sm font-medium">
{nextReservation.reference}
</span>
<ReservationStatus
status={nextReservation.status}
/>
</div>
<div className="grid gap-4 rounded-xl bg-slate-50 p-4 sm:grid-cols-2 dark:bg-slate-900">
<div>
<p className="text-xs text-muted-foreground">
Início
</p>
<p className="mt-1 text-sm font-medium">
{dateTime.format(
new Date(
nextReservation.starts_at,
),
)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Fim
</p>
<p className="mt-1 text-sm font-medium">
{dateTime.format(
new Date(
nextReservation.ends_at,
),
)}
</p>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">
Equipamento
</p>
<p className="mt-1 text-sm font-medium">
{nextReservation.items
.map((item) => item.asset.name)
.join(', ')}
</p>
</div>
</div>
) : (
<div className="rounded-xl border border-dashed py-10 text-center">
<PackageCheck className="mx-auto size-8 text-slate-300" />
<p className="mt-3 text-sm font-medium">
Ainda não tem requisições futuras.
</p>
<Button
asChild
variant="link"
className="mt-1"
>
<Link href="/equipamentos">
Explorar catálogo
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Atividade recente</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/requisicoes">
Ver todas <ArrowRight />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-1">
{recentReservations.length > 0 ? (
recentReservations.map((reservation) => (
<div
key={reservation.id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{reservation.reference}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{dateTime.format(
new Date(
reservation.starts_at,
),
)}
</p>
</div>
<ReservationStatus
status={reservation.status}
/>
</div>
))
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
Sem atividade recente.
</p>
)}
</CardContent>
</Card>
</section>
</main>
</>
);
}
function SummaryCard({
icon: Icon,
label,
value,
tone,
}: {
icon: typeof Clock3;
label: string;
value: number;
tone: 'amber' | 'emerald' | 'blue';
}) {
const colors = {
amber: 'bg-amber-50 text-amber-700',
emerald: 'bg-emerald-50 text-emerald-700',
blue: 'bg-blue-50 text-blue-700',
};
return (
<Card className="gap-3 border-slate-200 py-5 shadow-none dark:border-slate-800">
<CardContent className="flex items-center gap-4">
<span
className={`flex size-11 items-center justify-center rounded-xl ${colors[tone]}`}
>
<Icon className="size-5" />
</span>
<div>
<p className="text-2xl font-semibold">{value}</p>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
}
Dashboard.layout = {
breadcrumbs: [{ title: 'Visão geral', href: '/dashboard' }],
};

View File

@@ -0,0 +1,275 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
ArrowLeft,
CalendarClock,
Check,
MapPin,
PackageCheck,
} from 'lucide-react';
import type { FormEvent } from 'react';
import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { Asset } from '@/types';
export default function CreateReservation({
assets,
selectedAssetId,
}: {
assets: Asset[];
selectedAssetId: number;
}) {
const form = useForm({
purpose: '',
usage_location: '',
starts_at: '',
ends_at: '',
asset_ids: selectedAssetId ? [selectedAssetId] : ([] as number[]),
terms_accepted: false,
});
function submit(event: FormEvent) {
event.preventDefault();
form.post('/requisicoes');
}
function toggleAsset(id: number, checked: boolean) {
form.setData(
'asset_ids',
checked
? [...form.data.asset_ids, id]
: form.data.asset_ids.filter((item) => item !== id),
);
}
return (
<>
<Head title="Nova requisição" />
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Novo pedido"
title="Criar requisição"
description="Selecione o período, os equipamentos e indique como serão utilizados."
action={
<Button asChild variant="ghost">
<Link href="/equipamentos">
<ArrowLeft /> Voltar ao catálogo
</Link>
</Button>
}
/>
<form
onSubmit={submit}
className="grid gap-6 lg:grid-cols-[1fr_320px]"
>
<div className="space-y-6">
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarClock className="size-5 text-primary" />{' '}
Período e utilização
</CardTitle>
</CardHeader>
<CardContent className="grid gap-5 sm:grid-cols-2">
<Field
label="Início"
error={form.errors.starts_at}
>
<Input
type="datetime-local"
value={form.data.starts_at}
onChange={(e) =>
form.setData(
'starts_at',
e.target.value,
)
}
/>
</Field>
<Field label="Fim" error={form.errors.ends_at}>
<Input
type="datetime-local"
value={form.data.ends_at}
onChange={(e) =>
form.setData(
'ends_at',
e.target.value,
)
}
/>
</Field>
<div className="sm:col-span-2">
<Field
label="Local de utilização"
error={form.errors.usage_location}
>
<Input
value={form.data.usage_location}
onChange={(e) =>
form.setData(
'usage_location',
e.target.value,
)
}
placeholder="Ex.: Auditório principal"
/>
</Field>
</div>
<div className="sm:col-span-2">
<Label htmlFor="purpose">Finalidade</Label>
<textarea
id="purpose"
value={form.data.purpose}
onChange={(e) =>
form.setData(
'purpose',
e.target.value,
)
}
rows={4}
placeholder="Descreva brevemente a atividade e a necessidade do equipamento…"
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"
/>
<InputError message={form.errors.purpose} />
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-none dark:border-slate-800">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PackageCheck className="size-5 text-primary" />{' '}
Equipamentos
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{assets.map((asset) => {
const checked =
form.data.asset_ids.includes(asset.id);
return (
<label
key={asset.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition ${checked ? 'border-primary bg-primary/5' : 'hover:bg-slate-50 dark:hover:bg-slate-900'}`}
>
<Checkbox
checked={checked}
onCheckedChange={(value) =>
toggleAsset(
asset.id,
Boolean(value),
)
}
/>
<div className="min-w-0 flex-1">
<div className="flex justify-between gap-3">
<span className="font-medium">
{asset.name}
</span>
<span className="font-mono text-xs text-muted-foreground">
{asset.asset_tag}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asset.category_name} ·{' '}
{asset.model_name}
</p>
{asset.location_name && (
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />
{asset.location_name}
</p>
)}
</div>
</label>
);
})}
<InputError message={form.errors.asset_ids} />
</CardContent>
</Card>
</div>
<aside>
<Card className="sticky top-6 border-slate-200 shadow-none dark:border-slate-800">
<CardHeader>
<CardTitle>Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div>
<p className="text-xs text-muted-foreground">
Equipamentos selecionados
</p>
<p className="mt-1 text-2xl font-semibold">
{form.data.asset_ids.length}
</p>
</div>
<div className="flex items-start gap-3 border-t pt-5">
<Checkbox
id="terms"
checked={form.data.terms_accepted}
onCheckedChange={(value) =>
form.setData(
'terms_accepted',
Boolean(value),
)
}
/>
<Label
htmlFor="terms"
className="text-xs leading-5 font-normal"
>
Confirmo que conheço as condições de
utilização e que entregarei os
equipamentos no estado e prazo
acordados.
</Label>
</div>
<InputError
message={form.errors.terms_accepted}
/>
<Button
type="submit"
className="w-full"
disabled={form.processing}
>
<Check />{' '}
{form.processing
? 'A submeter…'
: 'Submeter para aprovação'}
</Button>
<p className="text-center text-xs text-muted-foreground">
O equipamento fica pré-reservado até à
decisão.
</p>
</CardContent>
</Card>
</aside>
</form>
</main>
</>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<Label>{label}</Label>
<div className="mt-2">{children}</div>
<InputError message={error} />
</div>
);
}
CreateReservation.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Nova', href: '/requisicoes/nova' },
],
};

View File

@@ -0,0 +1,98 @@
import { Head, Link } from '@inertiajs/react';
import { CalendarDays, Plus } from 'lucide-react';
import { PageHeading } from '@/components/page-heading';
import { ReservationStatus } from '@/components/reservation-status';
import { Button } from '@/components/ui/button';
import type { Paginated, Reservation } from '@/types';
export default function ReservationsIndex({
reservations,
}: {
reservations: Paginated<Reservation>;
}) {
const format = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
});
return (
<>
<Head title="As minhas requisições" />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Histórico"
title="As minhas requisições"
description="Acompanhe decisões, datas e equipamentos associados aos seus pedidos."
action={
<Button asChild>
<Link href="/requisicoes/nova">
<Plus /> Nova requisição
</Link>
</Button>
}
/>
{reservations.data.length ? (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[150px_1fr_220px_170px] gap-4 border-b bg-slate-50 px-5 py-3 text-xs font-medium text-muted-foreground uppercase md:grid dark:bg-slate-900">
<span>Referência</span>
<span>Equipamentos</span>
<span>Período</span>
<span>Estado</span>
</div>
{reservations.data.map((reservation) => (
<div
key={reservation.id}
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_170px] md:items-center"
>
<span className="font-mono text-sm font-medium">
{reservation.reference}
</span>
<div>
<p className="text-sm font-medium">
{reservation.items
.map((item) => item.asset.name)
.join(', ')}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{reservation.purpose}
</p>
</div>
<div className="text-xs text-muted-foreground">
<p>
{format.format(
new Date(reservation.starts_at),
)}
</p>
<p>
{format.format(
new Date(reservation.ends_at),
)}
</p>
</div>
<ReservationStatus
status={reservation.status}
/>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed py-16 text-center">
<CalendarDays className="mx-auto size-9 text-slate-300" />
<h2 className="mt-4 font-medium">
Ainda não criou requisições
</h2>
<Button asChild variant="link">
<Link href="/equipamentos">
Consultar equipamentos
</Link>
</Button>
</div>
)}
</main>
</>
);
}
ReservationsIndex.layout = {
breadcrumbs: [{ title: 'Requisições', href: '/requisicoes' }],
};

View File

@@ -0,0 +1,32 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import Heading from '@/components/heading';
import { edit as editAppearance } from '@/routes/appearance';
export default function Appearance() {
return (
<>
<Head title="Appearance settings" />
<h1 className="sr-only">Appearance settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update the appearance settings for your account"
/>
<AppearanceTabs />
</div>
</>
);
}
Appearance.layout = {
breadcrumbs: [
{
title: 'Appearance settings',
href: editAppearance(),
},
],
};

View File

@@ -0,0 +1,138 @@
import { Form, Head, usePage } from '@inertiajs/react';
import { Link } from '@inertiajs/react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import type { Auth } from '@/types';
type PageProps = {
auth: Auth;
};
export default function Profile({
mustVerifyEmail,
status,
}: {
mustVerifyEmail: boolean;
status?: string;
}) {
const { auth } = usePage<PageProps>().props;
return (
<>
<Head title="Profile settings" />
<h1 className="sr-only">Profile settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Profile"
description="Update your name and email address"
/>
<Form
{...ProfileController.update.form()}
options={{
preserveScroll: true,
}}
className="space-y-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="mt-1 block w-full"
defaultValue={auth.user.name}
name="name"
required
autoComplete="name"
placeholder="Full name"
/>
<InputError
className="mt-2"
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
className="mt-1 block w-full"
defaultValue={auth.user.email}
name="email"
required
autoComplete="username"
placeholder="Email address"
/>
<InputError
className="mt-2"
message={errors.email}
/>
</div>
{mustVerifyEmail &&
auth.user.email_verified_at === null && (
<div>
<p className="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.{' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to re-send the
verification email.
</Link>
</p>
{status ===
'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been
sent to your email address.
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-profile-button"
>
Save
</Button>
</div>
</>
)}
</Form>
</div>
<DeleteUser />
</>
);
}
Profile.layout = {
breadcrumbs: [
{
title: 'Profile settings',
href: edit(),
},
],
};

View File

@@ -0,0 +1,147 @@
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import type { Props as ManagePasskeysProps } from '@/components/manage-passkeys';
import ManagePasskeys from '@/components/manage-passkeys';
import type { Props as ManageTwoFactorProps } from '@/components/manage-two-factor';
import ManageTwoFactor from '@/components/manage-two-factor';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/security';
type Props = {
passwordRules: string;
} & ManagePasskeysProps &
ManageTwoFactorProps;
export default function Security(props: Props) {
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
return (
<>
<Head title="Security settings" />
<h1 className="sr-only">Security settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<Form
{...SecurityController.update.form()}
options={{
preserveScroll: true,
}}
resetOnError={[
'password',
'password_confirmation',
'current_password',
]}
resetOnSuccess
onError={(errors) => {
if (errors.password) {
passwordInput.current?.focus();
}
if (errors.current_password) {
currentPasswordInput.current?.focus();
}
}}
className="space-y-6"
>
{({ errors, processing }) => (
<>
<div className="grid gap-2">
<Label htmlFor="current_password">
Current password
</Label>
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
ref={passwordInput}
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
passwordrules={props.passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
passwordrules={props.passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-password-button"
>
Save
</Button>
</div>
</>
)}
</Form>
</div>
<ManageTwoFactor
canManageTwoFactor={props.canManageTwoFactor}
requiresConfirmation={props.requiresConfirmation}
twoFactorEnabled={props.twoFactorEnabled}
/>
<ManagePasskeys
canManagePasskeys={props.canManagePasskeys}
passkeys={props.passkeys}
/>
</>
);
}
Security.layout = {
breadcrumbs: [
{
title: 'Security settings',
href: edit(),
},
],
};