feat: complete administration and reporting

This commit is contained in:
2026-08-04 17:40:52 +01:00
parent d829f34580
commit 7a4ac48aaf
42 changed files with 2595 additions and 76 deletions

View File

@@ -0,0 +1,144 @@
import { Head, useForm } from '@inertiajs/react';
import { Mails, Save } 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 { Input } from '@/components/ui/input';
type Template = { subject: string; message: string };
type Definition = Template & { label: string };
export default function NotificationTemplates({
definitions,
templates,
variables,
}: {
definitions: Record<string, Definition>;
templates: Record<string, Template>;
variables: string[];
}) {
const form = useForm({ templates });
function submit(event: FormEvent) {
event.preventDefault();
form.put('/administracao/notificacoes/modelos', {
preserveScroll: true,
});
}
function update(eventName: string, field: keyof Template, value: string) {
form.setData('templates', {
...form.data.templates,
[eventName]: { ...form.data.templates[eventName], [field]: value },
});
}
return (
<>
<Head title="Modelos de email" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Administração"
title="Modelos de notificação"
description="Personalize o assunto e a mensagem usados no email e na caixa interna, mantendo as variáveis funcionais."
/>
<p className="rounded-xl border bg-muted/40 p-4 text-sm text-muted-foreground">
Variáveis disponíveis:{' '}
{variables.map((variable) => (
<code
key={variable}
className="mx-1 rounded bg-background px-1.5 py-0.5"
>
{variable}
</code>
))}
</p>
<form onSubmit={submit} className="space-y-4">
<div className="grid gap-4 lg:grid-cols-2">
{Object.entries(definitions).map(
([eventName, definition]) => (
<Card
key={eventName}
className="gap-4 shadow-none"
>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mails className="size-4 text-primary" />
{definition.label}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<label className="space-y-1 text-sm font-medium">
Assunto
<Input
value={
form.data.templates[
eventName
].subject
}
onChange={(event) =>
update(
eventName,
'subject',
event.target.value,
)
}
/>
</label>
<InputError
message={
form.errors[
`templates.${eventName}.subject`
]
}
/>
<label className="block space-y-1 text-sm font-medium">
Mensagem
<textarea
className="min-h-24 w-full rounded-md border bg-transparent px-3 py-2 text-sm"
value={
form.data.templates[
eventName
].message
}
onChange={(event) =>
update(
eventName,
'message',
event.target.value,
)
}
/>
</label>
<InputError
message={
form.errors[
`templates.${eventName}.message`
]
}
/>
</CardContent>
</Card>
),
)}
</div>
<Button type="submit" disabled={form.processing}>
<Save /> Guardar modelos
</Button>
</form>
</main>
</>
);
}
NotificationTemplates.layout = {
breadcrumbs: [
{ title: 'Administração', href: '/administracao/notificacoes/modelos' },
{
title: 'Modelos de email',
href: '/administracao/notificacoes/modelos',
},
],
};

View File

@@ -0,0 +1,112 @@
import { Head, useForm } from '@inertiajs/react';
import { Clock3, Save, ShieldCheck } 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 { Input } from '@/components/ui/input';
type Definition = {
label: string;
description: string;
default: number;
min: number;
max: number;
unit: string;
};
export default function ReservationPolicyPage({
definitions,
values,
}: {
definitions: Record<string, Definition>;
values: Record<string, number>;
}) {
const form = useForm(values);
function submit(event: FormEvent) {
event.preventDefault();
form.put('/administracao/regras', { preserveScroll: true });
}
return (
<>
<Head title="Regras de reserva" />
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Administração"
title="Regras de reserva"
description="Parâmetros aplicados centralmente a novos pedidos, conflitos temporais e prazos de aprovação."
/>
<div className="flex gap-3 rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900 dark:bg-sky-950/30 dark:text-sky-100">
<ShieldCheck className="mt-0.5 size-5 shrink-0" />
<p>
As margens já gravadas permanecem associadas às reservas
existentes. Alterações entram em vigor imediatamente
para novas requisições.
</p>
</div>
<form onSubmit={submit} className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
{Object.entries(definitions).map(
([key, definition]) => (
<Card key={key} className="gap-4 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock3 className="size-4 text-primary" />
{definition.label}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="min-h-10 text-sm text-muted-foreground">
{definition.description}
</p>
<div className="flex items-center gap-3">
<Input
type="number"
min={definition.min}
max={definition.max}
value={form.data[key]}
onChange={(event) =>
form.setData(
key,
Number(
event.target.value,
),
)
}
/>
<span className="min-w-24 text-sm text-muted-foreground">
{definition.unit}
</span>
</div>
<InputError
message={form.errors[key]}
/>
<p className="text-xs text-muted-foreground">
Intervalo permitido:{' '}
{definition.min}–{definition.max}
</p>
</CardContent>
</Card>
),
)}
</div>
<Button type="submit" disabled={form.processing}>
<Save /> Guardar e aplicar regras
</Button>
</form>
</main>
</>
);
}
ReservationPolicyPage.layout = {
breadcrumbs: [
{ title: 'Administração', href: '/administracao/regras' },
{ title: 'Regras de reserva', href: '/administracao/regras' },
],
};

View File

@@ -0,0 +1,423 @@
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Plus, Search, UserCog } from 'lucide-react';
import type { FormEvent } from 'react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import type { Paginated } from '@/types';
type Role = { id: number; name: string; description: string | null };
type ManagedUser = {
id: number;
uuid: string;
name: string;
email: string;
department: string | null;
snipe_user_id: number | null;
active: boolean;
roles: Role[];
};
type UserFormState = {
name: string;
email: string;
department: string;
snipeUserId: string;
password: string;
active: boolean;
roles: string[];
};
const roleLabels: Record<string, string> = {
requester: 'Requisitante',
approver: 'Aprovador',
inventory_operator: 'Operador',
administrator: 'Administrador',
auditor: 'Auditor',
};
export default function UsersIndex({
users,
roles,
filters,
}: {
users: Paginated<ManagedUser>;
roles: Role[];
filters: { search?: string; role?: string; status?: string };
}) {
const [search, setSearch] = useState(filters.search ?? '');
const [role, setRole] = useState(filters.role ?? '');
const [status, setStatus] = useState(filters.status ?? '');
const [creating, setCreating] = useState(false);
function submitFilters(event: FormEvent) {
event.preventDefault();
router.get('/administracao/utilizadores', {
search: search || undefined,
role: role || undefined,
status: status || undefined,
});
}
return (
<>
<Head title="Utilizadores" />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Administração"
title="Utilizadores e perfis"
description="Gerir acesso, responsabilidades e a associação de cada conta ao utilizador correspondente no Snipe-IT."
action={
<Button onClick={() => setCreating((value) => !value)}>
<Plus /> Novo utilizador
</Button>
}
/>
{creating && (
<Card className="shadow-none">
<CardHeader>
<CardTitle>Criar conta institucional</CardTitle>
</CardHeader>
<CardContent>
<UserForm
roles={roles}
initial={{
name: '',
email: '',
department: '',
snipeUserId: '',
password: '',
active: true,
roles: ['requester'],
}}
passwordRequired
submitLabel="Criar utilizador"
onSubmit={(data, finish) =>
router.post(
'/administracao/utilizadores',
payload(data),
{
preserveScroll: true,
onSuccess: () => setCreating(false),
onFinish: finish,
},
)
}
/>
</CardContent>
</Card>
)}
<form
onSubmit={submitFilters}
className="grid gap-2 rounded-xl border bg-card p-4 md:grid-cols-[1fr_220px_180px_auto]"
>
<div className="relative">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
className="pl-9"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Nome, email ou departamento…"
/>
</div>
<select
className="rounded-md border bg-transparent px-3 text-sm"
value={role}
onChange={(event) => setRole(event.target.value)}
>
<option value="">Todos os perfis</option>
{roles.map((item) => (
<option key={item.id} value={item.name}>
{roleLabels[item.name] ?? item.name}
</option>
))}
</select>
<select
className="rounded-md border bg-transparent px-3 text-sm"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<option value="">Ativos e inativos</option>
<option value="active">Ativos</option>
<option value="inactive">Inativos</option>
</select>
<Button type="submit" variant="secondary">
Pesquisar
</Button>
</form>
<section className="space-y-4">
{users.data.map((user) => (
<Card key={user.id} className="gap-4 shadow-none">
<CardHeader className="flex-row items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<UserCog className="size-4 text-primary" />
{user.name}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{user.email} ·{' '}
{user.department ?? 'Sem departamento'}
</p>
</div>
<div className="flex flex-wrap justify-end gap-1">
<Badge
variant={
user.active
? 'default'
: 'secondary'
}
>
{user.active ? 'Ativo' : 'Inativo'}
</Badge>
{user.roles.map((item) => (
<Badge key={item.id} variant="outline">
{roleLabels[item.name] ?? item.name}
</Badge>
))}
</div>
</CardHeader>
<CardContent>
<details>
<summary className="cursor-pointer text-sm font-medium text-primary">
Editar conta e perfis
</summary>
<div className="mt-4">
<UserForm
roles={roles}
initial={{
name: user.name,
email: user.email,
department:
user.department ?? '',
snipeUserId:
user.snipe_user_id?.toString() ??
'',
password: '',
active: user.active,
roles: user.roles.map(
(item) => item.name,
),
}}
submitLabel="Guardar alterações"
onSubmit={(data, finish) =>
router.put(
`/administracao/utilizadores/${user.id}`,
payload(data),
{
preserveScroll: true,
onFinish: finish,
},
)
}
/>
</div>
</details>
</CardContent>
</Card>
))}
</section>
{users.last_page > 1 && (
<nav
className="flex flex-wrap gap-2"
aria-label="Paginação"
>
{users.links.map((link) =>
link.url ? (
<Button
key={link.label}
asChild
variant={
link.active ? 'default' : 'outline'
}
size="sm"
>
<Link
href={link.url}
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
</Button>
) : null,
)}
</nav>
)}
</main>
</>
);
}
function UserForm({
roles,
initial,
passwordRequired = false,
submitLabel,
onSubmit,
}: {
roles: Role[];
initial: UserFormState;
passwordRequired?: boolean;
submitLabel: string;
onSubmit: (data: UserFormState, finish: () => void) => void;
}) {
const [data, setData] = useState(initial);
const [processing, setProcessing] = useState(false);
const { errors } = usePage().props;
function submit(event: FormEvent) {
event.preventDefault();
setProcessing(true);
onSubmit(data, () => setProcessing(false));
}
function toggleRole(name: string) {
setData((current) => ({
...current,
roles: current.roles.includes(name)
? current.roles.filter((item) => item !== name)
: [...current.roles, name],
}));
}
return (
<form
onSubmit={submit}
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<Field label="Nome">
<Input
value={data.name}
onChange={(event) =>
setData({ ...data, name: event.target.value })
}
required
/>
</Field>
<Field label="Email institucional">
<Input
type="email"
value={data.email}
onChange={(event) =>
setData({ ...data, email: event.target.value })
}
required
/>
</Field>
<Field label="Departamento">
<Input
value={data.department}
onChange={(event) =>
setData({ ...data, department: event.target.value })
}
/>
</Field>
<Field label="ID do utilizador no Snipe-IT">
<Input
type="number"
min={1}
value={data.snipeUserId}
onChange={(event) =>
setData({ ...data, snipeUserId: event.target.value })
}
/>
</Field>
<Field
label={
passwordRequired
? 'Palavra-passe temporária'
: 'Nova palavra-passe (opcional)'
}
>
<Input
type="password"
minLength={12}
value={data.password}
onChange={(event) =>
setData({ ...data, password: event.target.value })
}
required={passwordRequired}
/>
</Field>
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
<input
type="checkbox"
checked={data.active}
onChange={(event) =>
setData({ ...data, active: event.target.checked })
}
/>
Conta ativa
</label>
<fieldset className="space-y-2 md:col-span-2 xl:col-span-3">
<legend className="text-sm font-medium">Perfis</legend>
<div className="flex flex-wrap gap-3">
{roles.map((role) => (
<label
key={role.id}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
checked={data.roles.includes(role.name)}
onChange={() => toggleRole(role.name)}
/>
{roleLabels[role.name] ?? role.name}
</label>
))}
</div>
</fieldset>
<div className="space-y-1 md:col-span-2 xl:col-span-3">
{Object.values(errors).map((message, index) => (
<InputError key={`${message}-${index}`} message={message} />
))}
<Button
type="submit"
disabled={processing || data.roles.length === 0}
>
{submitLabel}
</Button>
</div>
</form>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="space-y-1 text-sm font-medium">
{label}
{children}
</label>
);
}
function payload(data: UserFormState) {
return {
name: data.name,
email: data.email,
department: data.department || null,
snipe_user_id: data.snipeUserId ? Number(data.snipeUserId) : null,
password: data.password || null,
active: data.active,
roles: data.roles,
};
}
UsersIndex.layout = {
breadcrumbs: [
{ title: 'Administração', href: '/administracao/utilizadores' },
{ title: 'Utilizadores', href: '/administracao/utilizadores' },
],
};