feat: establish reservation portal foundation
This commit is contained in:
231
resources/js/pages/assets/index.tsx
Normal file
231
resources/js/pages/assets/index.tsx
Normal 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' }],
|
||||
};
|
||||
66
resources/js/pages/auth/confirm-password.tsx
Normal file
66
resources/js/pages/auth/confirm-password.tsx
Normal 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.',
|
||||
};
|
||||
69
resources/js/pages/auth/forgot-password.tsx
Normal file
69
resources/js/pages/auth/forgot-password.tsx
Normal 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',
|
||||
};
|
||||
114
resources/js/pages/auth/login.tsx
Normal file
114
resources/js/pages/auth/login.tsx
Normal 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.',
|
||||
};
|
||||
96
resources/js/pages/auth/reset-password.tsx
Normal file
96
resources/js/pages/auth/reset-password.tsx
Normal 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',
|
||||
};
|
||||
133
resources/js/pages/auth/two-factor-challenge.tsx
Normal file
133
resources/js/pages/auth/two-factor-challenge.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
resources/js/pages/auth/verify-email.tsx
Normal file
46
resources/js/pages/auth/verify-email.tsx
Normal 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.',
|
||||
};
|
||||
233
resources/js/pages/dashboard.tsx
Normal file
233
resources/js/pages/dashboard.tsx
Normal 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' }],
|
||||
};
|
||||
275
resources/js/pages/reservations/create.tsx
Normal file
275
resources/js/pages/reservations/create.tsx
Normal 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' },
|
||||
],
|
||||
};
|
||||
98
resources/js/pages/reservations/index.tsx
Normal file
98
resources/js/pages/reservations/index.tsx
Normal 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' }],
|
||||
};
|
||||
32
resources/js/pages/settings/appearance.tsx
Normal file
32
resources/js/pages/settings/appearance.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
138
resources/js/pages/settings/profile.tsx
Normal file
138
resources/js/pages/settings/profile.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
147
resources/js/pages/settings/security.tsx
Normal file
147
resources/js/pages/settings/security.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user